diff --git a/.cargo/config.toml b/.cargo/config.toml index c9c364637..5ac391fb2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,4 +5,4 @@ rustflags = ["-C", "target-feature=+crt-static"] [alias] workshop = "run -p build-workshop --" -xtask = "run -p xtask --" +xtask = "run -p build-xtask --" diff --git a/.cursor/rules/workshop-architecture.mdc b/.cursor/rules/workshop-architecture.mdc index 44fe02562..d962c2908 100644 --- a/.cursor/rules/workshop-architecture.mdc +++ b/.cursor/rules/workshop-architecture.mdc @@ -22,4 +22,4 @@ alwaysApply: false ## File ceiling -- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p xtask` enforces the ceiling, the tier graph, and lint inheritance over the Rust files in the workshop crates carrying the marker; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. +- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the ceiling, the tier graph, and lint inheritance over the Rust files in the workshop crates carrying the marker; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. diff --git a/AGENTS.md b/AGENTS.md index 9fb4afb35..a4985a283 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Multi-crate Rust workspace for the PromptForge pipeline runtime, inference gatew - Workshop crates are named workshop-* and must not depend on gateway crates - Gateway crates are named gateway-* and must not depend on promptforge or workshop crates - PromptForge crates are named promptforge-* and must not depend on gateway or workshop crates +- PromptForge is one door: crates outside the promptforge-* family may depend only on promptforge-api, never on the internal promptforge-* substrate crates - Shared crates are named shared-*, contain the public API surface across products and downstream crates, and must not depend on any product crates - Crates named build-* are for building specific outputs - Dependency rules bind all kinds: normal, dev, build, and target-specific dependencies @@ -38,11 +39,19 @@ Multi-crate Rust workspace for the PromptForge pipeline runtime, inference gatew - Unsafe code stays in its explicitly owned boundary. Every unsafe block documents its safety invariants immediately before the block. - Comments explain a non-obvious constraint, ordering requirement, or workaround. Every platform or external-bug workaround cites its upstream issue URL in the explanatory comment. +## Verification + +- Full suite: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features`, then doctests via `cargo test --workspace --exclude workshop --exclude workshop-server --all-features --doc`; workshop crates separately: `cargo nextest run --locked -p workshop -p workshop-server`. +- Linter: `cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings` (workshop: `cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings`). +- Formatter: `cargo fmt --all --check`. +- Docs: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server` with `RUSTDOCFLAGS="-D warnings"`; user guide: `mdbook build guide`. Rustdoc lints are not covered by clippy; never skip the docs gate. +- Boundary and structural harness: `cargo test -p build-xtask`. + ## Structural Rules - Dependencies flow one way: shell -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. On the SPA side, lazy-loaded panels never import the boot shell; shared code lives in services/ or base/. - Every workshop-* crate's lib.rs opens with a //! doc carrying a `## Invariants` marker that lists what the crate may depend on and what it may not. Read it before adding an import. Every SPA concern directory (ui/editor/, ui/agent/, etc.) has the same in its index.ts. -- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p xtask` enforces the tier graph, the lint inheritance, and the ceiling over the Rust files in the workshop crates carrying the marker; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. +- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the tier graph, the lint inheritance, the ceiling over the Rust files in the workshop crates carrying the marker, and the product-boundary matrix above (including the one-door rule) across every workspace manifest; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. ## SPA and CSS Rules diff --git a/Cargo.lock b/Cargo.lock index d87c4478d..0bb16e648 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,6 +580,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "build-xtask" +version = "0.0.0" +dependencies = [ + "anyhow", + "tempfile", + "toml 0.8.2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -4804,81 +4813,30 @@ dependencies = [ ] [[package]] -name = "product-integration-tests" -version = "0.3.0" -dependencies = [ - "axum", - "gateway", - "promptforge-model-client", - "serde_json", - "tempfile", - "tokio", -] - -[[package]] -name = "promptforge" -version = "0.3.0" -dependencies = [ - "promptforge-agent", - "promptforge-core", -] - -[[package]] -name = "promptforge-agent" -version = "0.3.0" -dependencies = [ - "async-trait", - "axum", - "mlua", - "promptforge-core-support", - "promptforge-lua", - "promptforge-model-client", - "promptforge-store", - "promptforge-tools", - "promptforge-vfs", - "serde_json", - "shared-vfs", - "thiserror 2.0.19", - "tokio", -] - -[[package]] -name = "promptforge-core" +name = "promptforge-api" version = "0.3.0" dependencies = [ "async-trait", "axum", "criterion", "mlua", - "promptforge-core-support", "promptforge-lua", "promptforge-model-client", "promptforge-parser", "promptforge-store", "promptforge-tool-picker", - "promptforge-tools", "promptforge-vfs", "promptforge-web-search", "rand 0.9.5", "serde", "serde_json", + "shared-promptforge-api", "shared-vfs", "thiserror 2.0.19", "time", "tokio", ] -[[package]] -name = "promptforge-core-support" -version = "0.3.0" -dependencies = [ - "rand 0.9.5", - "serde", - "serde_json", - "tokio", - "tokio-util", -] - [[package]] name = "promptforge-lua" version = "0.3.0" @@ -4886,12 +4844,11 @@ dependencies = [ "async-trait", "criterion", "mlua", - "promptforge-core-support", "promptforge-model-client", "promptforge-store", - "promptforge-tools", "promptforge-vfs", "serde_json", + "shared-promptforge-api", "shared-vfs", "thiserror 2.0.19", "tokio", @@ -4902,13 +4859,11 @@ name = "promptforge-model-client" version = "0.3.0" dependencies = [ "axum", - "futures-util", - "promptforge-core-support", "promptforge-tool-picker", "reqwest 0.12.28", "serde", "serde_json", - "shared-progress", + "shared-promptforge-api", "thiserror 2.0.19", "tokio", "tracing", @@ -4920,11 +4875,11 @@ name = "promptforge-parser" version = "0.3.0" dependencies = [ "mlua", - "promptforge-core-support", "promptforge-lua", "pulldown-cmark", "serde", "serde_yaml_ng", + "shared-promptforge-api", "thiserror 2.0.19", ] @@ -4957,15 +4912,6 @@ dependencies = [ "tokenizers", ] -[[package]] -name = "promptforge-tools" -version = "0.3.0" -dependencies = [ - "async-trait", - "serde_json", - "thiserror 2.0.19", -] - [[package]] name = "promptforge-vfs" version = "0.3.0" @@ -4979,10 +4925,10 @@ version = "0.3.0" dependencies = [ "async-trait", "axum", - "promptforge-tools", "reqwest 0.12.28", "serde", "serde_json", + "shared-promptforge-api", "thiserror 2.0.19", "tokio", "url", @@ -5000,10 +4946,10 @@ dependencies = [ "htmd", "ipnet", "mime", - "promptforge-tools", "readabilityrs", "reqwest 0.12.28", "serde_json", + "shared-promptforge-api", "thiserror 2.0.19", "tokio", "tracing", @@ -6104,6 +6050,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "shared-promptforge-api" +version = "0.3.0" +dependencies = [ + "async-trait", + "rand 0.9.5", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-util", +] + [[package]] name = "shared-sidecar" version = "0.3.0" @@ -8561,12 +8520,11 @@ dependencies = [ "arc-swap", "axum", "futures-util", - "promptforge-core-support", - "promptforge-model-client", "reqwest 0.12.28", "serde", "serde_json", "shared-progress", + "shared-promptforge-api", "shared-sidecar", "tempfile", "thiserror 2.0.19", @@ -8598,9 +8556,9 @@ dependencies = [ name = "workshop-protocol" version = "0.0.0" dependencies = [ - "promptforge-core-support", "serde", "serde_json", + "shared-promptforge-api", ] [[package]] @@ -8622,19 +8580,14 @@ dependencies = [ "build-ui", "futures-util", "open", - "promptforge-agent", - "promptforge-core", - "promptforge-core-support", - "promptforge-model-client", - "promptforge-tool-picker", - "promptforge-tools", - "promptforge-vfs", + "promptforge-api", "reqwest 0.12.28", "rust-embed", "serde", "serde_json", "shared-loopback", "shared-progress", + "shared-promptforge-api", "shared-sidecar", "socket2", "tempfile", @@ -8663,16 +8616,11 @@ dependencies = [ "async-trait", "axum", "futures-util", - "promptforge-agent", - "promptforge-core", - "promptforge-core-support", - "promptforge-model-client", - "promptforge-tool-picker", - "promptforge-tools", - "promptforge-vfs", + "promptforge-api", "rand 0.9.5", "serde", "serde_json", + "shared-promptforge-api", "shared-vfs", "tempfile", "thiserror 2.0.19", @@ -8975,15 +8923,6 @@ dependencies = [ "markup5ever 0.38.0", ] -[[package]] -name = "xtask" -version = "0.0.0" -dependencies = [ - "anyhow", - "tempfile", - "toml 0.8.2", -] - [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index bdbc1cf4e..e97122314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,8 @@ repository = "https://github.com/cppalliance/promptforge" [workspace.dependencies] base64 = "0.22" bytes = "1" -promptforge = { path = "crates/promptforge", version = "0.3.0" } -promptforge-core = { path = "crates/promptforge-core", version = "0.3.0" } -promptforge-core-support = { path = "crates/promptforge-core-support", version = "0.3.0" } +promptforge-api = { path = "crates/promptforge-api", version = "0.3.0" } +shared-promptforge-api = { path = "crates/shared-promptforge-api", version = "0.3.0" } gateway = { path = "crates/gateway", version = "0.3.0" } gateway-config = { path = "crates/gateway-config", version = "0.3.0" } gateway-config-ui = { path = "crates/gateway-config-ui", version = "0.3.0" } @@ -41,7 +40,6 @@ promptforge-store = { path = "crates/promptforge-store", version = "0.3.0" } promptforge-vfs = { path = "crates/promptforge-vfs", version = "0.3.0" } promptforge-webfetch = { path = "crates/promptforge-webfetch", version = "0.3.0" } promptforge-tool-picker = { path = "crates/promptforge-tool-picker", version = "0.3.0" } -promptforge-tools = { path = "crates/promptforge-tools", version = "0.3.0" } gateway-stt-engine = { path = "crates/gateway-stt-engine", version = "0.3.0" } gateway-stt-backend-whisper = { path = "crates/gateway-stt-backend-whisper", version = "0.3.0" } promptforge-web-search = { path = "crates/promptforge-web-search", version = "0.3.0" } @@ -56,7 +54,6 @@ workshop-registry = { path = "crates/workshop-registry", version = "0.0.0" } workshop-sessions = { path = "crates/workshop-sessions", version = "0.0.0" } workshop-workspace = { path = "crates/workshop-workspace", version = "0.0.0" } gateway-whisper-ffi = { path = "crates/gateway-whisper-ffi", version = "0.3.0" } -promptforge-agent = { path = "crates/promptforge-agent", version = "0.3.0" } pulldown-cmark = "0.12" serde = { version = "1", features = ["derive"] } serde_yaml_ng = "0.10" diff --git a/crates/xtask/Cargo.toml b/crates/build-xtask/Cargo.toml similarity index 92% rename from crates/xtask/Cargo.toml rename to crates/build-xtask/Cargo.toml index cfd56353e..9a250f09e 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/build-xtask/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "xtask" +name = "build-xtask" version = "0.0.0" publish = false edition.workspace = true diff --git a/crates/xtask/src/main.rs b/crates/build-xtask/src/main.rs similarity index 91% rename from crates/xtask/src/main.rs rename to crates/build-xtask/src/main.rs index 3405126e9..2eb0eb8e9 100644 --- a/crates/xtask/src/main.rs +++ b/crates/build-xtask/src/main.rs @@ -1,13 +1,14 @@ -//! `xtask` - workspace automation for the PromptForge repository. +//! `build-xtask` - workspace automation for the PromptForge repository. //! //! ## Invariants //! //! - Tier: tooling; depends on no workspace crates. The tidy-style -//! architecture checks run as tests (`cargo test -p xtask`); +//! architecture checks run as tests (`cargo test -p build-xtask`); //! `cargo xtask tidy` prints the same report on demand. //! - Every file in this crate stays under 500 lines; split first, then edit. mod new_crate; +mod product; mod tidy; use std::path::Path; diff --git a/crates/xtask/src/new_crate.rs b/crates/build-xtask/src/new_crate.rs similarity index 100% rename from crates/xtask/src/new_crate.rs rename to crates/build-xtask/src/new_crate.rs diff --git a/crates/build-xtask/src/product.rs b/crates/build-xtask/src/product.rs new file mode 100644 index 000000000..3f387fa10 --- /dev/null +++ b/crates/build-xtask/src/product.rs @@ -0,0 +1,374 @@ +//! Product-boundary check: codifies the AGENTS.md dependency matrix. +//! +//! Every workspace crate is classified by package name into a product +//! family, and its dependencies of every kind (normal, dev, build, and +//! target-specific) are checked against the matrix: +//! +//! - `promptforge-*` crates must not depend on gateway or workshop crates. +//! - `gateway`/`gateway-*` crates must not depend on promptforge or +//! workshop crates. +//! - `workshop`/`workshop-*` crates must not depend on gateway crates. +//! - `shared-*` crates must not depend on any product crate. +//! - One door: a crate outside the promptforge family may depend on +//! `promptforge-*` only through `promptforge-api`. + +use std::fs; +use std::path::Path; + +/// The dependency tables cargo recognizes, directly and under `[target]`. +const DEP_KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"]; + +/// The product family a package name belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Family { + Promptforge, + Gateway, + Workshop, + Shared, + Build, + /// Named after no product family; carries no matrix rules of its own. + Unaffiliated, +} + +/// Classify a package name into its product family. +fn family(package: &str) -> Family { + if package.starts_with("promptforge-") { + Family::Promptforge + } else if package == "gateway" || package.starts_with("gateway-") { + Family::Gateway + } else if package == "workshop" || package.starts_with("workshop-") { + Family::Workshop + } else if package.starts_with("shared-") { + Family::Shared + } else if package.starts_with("build-") { + Family::Build + } else { + Family::Unaffiliated + } +} + +/// Check every workspace manifest against the product-boundary matrix. +#[must_use] +pub(crate) fn product_boundary_violations(root: &Path) -> Vec { + let (crates, mut violations) = workspace_crates(root); + let members: Vec<&str> = crates.iter().map(|(package, _)| package.as_str()).collect(); + for (package, deps) in &crates { + for dep in deps { + // Only workspace members are bound by the matrix; a crates.io + // package that happens to carry a product prefix is not. + if !members.contains(&dep.as_str()) { + continue; + } + if let Some(reason) = boundary_breach(family(package), family(dep), dep) { + violations.push(format!("{package} depends on {dep}: {reason}")); + } + } + } + violations +} + +/// The reason a dependency from `from` to `to` breaches the matrix, or +/// `None` when the edge is legal. +fn boundary_breach(from: Family, to: Family, dep: &str) -> Option<&'static str> { + let family_rule = match (from, to) { + (Family::Promptforge, Family::Gateway | Family::Workshop) => { + Some("promptforge crates must not depend on gateway or workshop crates") + } + (Family::Gateway, Family::Promptforge | Family::Workshop) => { + Some("gateway crates must not depend on promptforge or workshop crates") + } + (Family::Workshop, Family::Gateway) => { + Some("workshop crates must not depend on gateway crates") + } + (Family::Shared, Family::Promptforge | Family::Gateway | Family::Workshop) => { + Some("shared crates must not depend on product crates") + } + _ => None, + }; + family_rule.or_else(|| { + if from != Family::Promptforge && to == Family::Promptforge && dep != "promptforge-api" { + Some("outside crates may depend on promptforge-* only through promptforge-api") + } else { + None + } + }) +} + +/// Every workspace crate's package name and dependency package names, +/// plus violations for manifests that could not be read or parsed. +fn workspace_crates(root: &Path) -> (Vec<(String, Vec)>, Vec) { + let mut crates = Vec::new(); + let mut violations = Vec::new(); + let crates_dir = root.join("crates"); + let entries = match fs::read_dir(&crates_dir) { + Ok(entries) => entries, + Err(error) => { + violations.push(format!( + "{}: unreadable crates directory: {error}", + crates_dir.display() + )); + return (crates, violations); + } + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + violations.push(format!( + "{}: unreadable directory entry: {error}", + crates_dir.display() + )); + continue; + } + }; + if !entry.path().is_dir() { + continue; + } + let manifest_path = entry.path().join("Cargo.toml"); + if !manifest_path.exists() { + // Not a crate: a manifestless directory declares no + // dependencies and cannot breach the boundary. + continue; + } + let text = match fs::read_to_string(&manifest_path) { + Ok(text) => text, + Err(error) => { + violations.push(format!( + "{}: unreadable manifest: {error}", + manifest_path.display() + )); + continue; + } + }; + let manifest = match toml::from_str::(&text) { + Ok(manifest) => manifest, + Err(error) => { + violations.push(format!( + "{}: unparseable manifest: {error}", + manifest_path.display() + )); + continue; + } + }; + let Some(package) = manifest + .get("package") + .and_then(|p| p.get("name")) + .and_then(toml::Value::as_str) + else { + violations.push(format!( + "{}: manifest has no package name", + manifest_path.display() + )); + continue; + }; + crates.push((package.to_owned(), manifest_dependencies(&manifest))); + } + (crates, violations) +} + +/// Every dependency package name declared in a manifest, across normal, +/// dev, build, and target-specific tables, resolving `package` renames. +fn manifest_dependencies(manifest: &toml::Value) -> Vec { + let mut names = Vec::new(); + for kind in DEP_KINDS { + if let Some(table) = manifest.get(kind).and_then(toml::Value::as_table) { + collect_deps(table, &mut names); + } + } + if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { + for target in targets.values() { + for kind in DEP_KINDS { + if let Some(table) = target.get(kind).and_then(toml::Value::as_table) { + collect_deps(table, &mut names); + } + } + } + } + names +} + +fn collect_deps(table: &toml::map::Map, names: &mut Vec) { + for (key, value) in table { + let package = value + .get("package") + .and_then(toml::Value::as_str) + .unwrap_or(key); + if !names.iter().any(|name| name == package) { + names.push(package.to_owned()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("build-xtask lives at /crates/build-xtask") + .to_path_buf() + } + + /// Write a minimal crate manifest into a fake workspace. + fn write_crate(root: &Path, dir_name: &str, package: &str, deps: &str) { + let dir = root.join("crates").join(dir_name); + std::fs::create_dir_all(&dir).expect("the crate directory creates"); + std::fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{package}\"\n{deps}"), + ) + .expect("the manifest writes"); + } + + #[test] + fn workspace_respects_the_product_boundary() { + let violations = product_boundary_violations(&workspace_root()); + assert!( + violations.is_empty(), + "product-boundary violations:\n{}", + violations.join("\n") + ); + } + + #[test] + fn an_outside_crate_reaching_past_the_one_door_is_reported() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "workshop-sessions", + "workshop-sessions", + "[dependencies]\npromptforge-lua = { path = \"../promptforge-lua\" }\npromptforge-api = { path = \"../promptforge-api\" }\n", + ); + write_crate(root.path(), "promptforge-lua", "promptforge-lua", ""); + write_crate(root.path(), "promptforge-api", "promptforge-api", ""); + let violations = product_boundary_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("workshop-sessions") + && violations[0].contains("promptforge-lua"), + "the violation names the crate and the forbidden dep: {violations:?}" + ); + } + + #[test] + fn a_gateway_crate_depending_on_promptforge_api_is_reported() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "gateway-routing", + "gateway-routing", + "[dependencies]\npromptforge-api = { path = \"../promptforge-api\" }\n", + ); + write_crate(root.path(), "promptforge-api", "promptforge-api", ""); + let violations = product_boundary_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("gateway-routing"), + "the violation names the gateway crate: {violations:?}" + ); + } + + #[test] + fn a_shared_crate_depending_on_a_product_crate_is_reported() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "shared-vfs", + "shared-vfs", + "[dependencies]\nworkshop-protocol = { path = \"../workshop-protocol\" }\n", + ); + write_crate(root.path(), "workshop-protocol", "workshop-protocol", ""); + let violations = product_boundary_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("shared-vfs") && violations[0].contains("workshop-protocol"), + "the violation names both crates: {violations:?}" + ); + } + + #[test] + fn dev_build_and_target_dependencies_are_checked() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "workshop-server", + "workshop-server", + "[dev-dependencies]\npromptforge-parser = { path = \"../promptforge-parser\" }\n\ + [target.'cfg(windows)'.dependencies]\ngateway-protocol = { path = \"../gateway-protocol\" }\n", + ); + write_crate(root.path(), "promptforge-parser", "promptforge-parser", ""); + write_crate(root.path(), "gateway-protocol", "gateway-protocol", ""); + let violations = product_boundary_violations(root.path()); + assert_eq!( + violations.len(), + 2, + "the dev-dependency and the target-specific dependency are both reported: {violations:?}" + ); + } + + #[test] + fn package_renames_are_resolved_before_classification() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "workshop-sessions", + "workshop-sessions", + "[dependencies]\npf = { package = \"promptforge-store\", path = \"../promptforge-store\" }\n", + ); + write_crate(root.path(), "promptforge-store", "promptforge-store", ""); + let violations = product_boundary_violations(root.path()); + assert_eq!( + violations.len(), + 1, + "the renamed dependency on promptforge-store is reported: {violations:?}" + ); + } + + #[test] + fn a_promptforge_crate_depending_on_gateway_or_workshop_is_reported() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "promptforge-api", + "promptforge-api", + "[dependencies]\ngateway-protocol = { path = \"../gateway-protocol\" }\nworkshop-protocol = { path = \"../workshop-protocol\" }\n", + ); + write_crate(root.path(), "gateway-protocol", "gateway-protocol", ""); + write_crate(root.path(), "workshop-protocol", "workshop-protocol", ""); + let violations = product_boundary_violations(root.path()); + assert_eq!(violations.len(), 2, "{violations:?}"); + assert!( + violations.iter().all(|v| v.contains("promptforge-api")), + "the violations name the promptforge crate: {violations:?}" + ); + } + + #[test] + fn an_unparseable_manifest_is_reported() { + let root = tempfile::TempDir::new().expect("tempdir"); + let dir = root.path().join("crates").join("broken"); + std::fs::create_dir_all(&dir).expect("the crate directory creates"); + std::fs::write(dir.join("Cargo.toml"), "not [valid toml").expect("the manifest writes"); + let violations = product_boundary_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("unparseable manifest"), + "the violation reports the parse failure: {violations:?}" + ); + } + + #[test] + fn family_classification_follows_the_naming_rules() { + assert_eq!(family("promptforge-api"), Family::Promptforge); + assert_eq!(family("gateway"), Family::Gateway); + assert_eq!(family("gateway-config"), Family::Gateway); + assert_eq!(family("workshop"), Family::Workshop); + assert_eq!(family("workshop-server"), Family::Workshop); + assert_eq!(family("shared-vfs"), Family::Shared); + assert_eq!(family("build-xtask"), Family::Build); + assert_eq!(family("serde"), Family::Unaffiliated); + } +} diff --git a/crates/xtask/src/tidy.rs b/crates/build-xtask/src/tidy.rs similarity index 97% rename from crates/xtask/src/tidy.rs rename to crates/build-xtask/src/tidy.rs index 923f559ef..25049e984 100644 --- a/crates/xtask/src/tidy.rs +++ b/crates/build-xtask/src/tidy.rs @@ -1,8 +1,9 @@ //! Tidy-style architecture checks for the workshop server decomposition. //! //! Each check returns a list of human-readable violations. The `#[test]` -//! wrappers assert the lists are empty, so `cargo test -p xtask` enforces -//! the architecture; `cargo xtask tidy` prints the same report on demand. +//! wrappers assert the lists are empty, so `cargo test -p build-xtask` +//! enforces the architecture; `cargo xtask tidy` prints the same report +//! on demand. use std::fs; use std::path::{Path, PathBuf}; @@ -30,6 +31,7 @@ pub(crate) fn all_violations(root: &Path) -> Vec { let mut violations = tier_dependency_violations(root); violations.extend(file_ceiling_violations(root)); violations.extend(lint_inheritance_violations(root)); + violations.extend(crate::product::product_boundary_violations(root)); violations } @@ -254,7 +256,7 @@ mod tests { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(2) - .expect("xtask lives at /crates/xtask") + .expect("build-xtask lives at /crates/build-xtask") .to_path_buf() } diff --git a/crates/gateway/src/dialect.rs b/crates/gateway/src/dialect.rs index 3b78e32ba..110d30bd6 100644 --- a/crates/gateway/src/dialect.rs +++ b/crates/gateway/src/dialect.rs @@ -1,5 +1,5 @@ //! Emulated tool-calling dialects: the Gemma3 `tool_code` content-fence -//! protocol, ported from `promptforge-core`'s `dialects::gemma3_tool_code`. +//! protocol, ported from `promptforge-api`'s `dialects::gemma3_tool_code`. //! //! Gemma has no native tool array, so a model configured with //! `tool_dialect = "gemma3_tool_code"` gets tool calling emulated at the diff --git a/crates/product-integration-tests/Cargo.toml b/crates/product-integration-tests/Cargo.toml deleted file mode 100644 index 25ebe4c24..000000000 --- a/crates/product-integration-tests/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "product-integration-tests" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "Boundary-neutral end-to-end compatibility tests for PromptForge products" - -[dev-dependencies] -axum.workspace = true -gateway.workspace = true -promptforge-model-client.workspace = true -serde_json.workspace = true -tempfile.workspace = true -tokio.workspace = true - -[lints] -workspace = true diff --git a/crates/product-integration-tests/src/lib.rs b/crates/product-integration-tests/src/lib.rs deleted file mode 100644 index 13b1cbf46..000000000 --- a/crates/product-integration-tests/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -//! Boundary-neutral end-to-end compatibility tests for separately owned products. diff --git a/crates/product-integration-tests/tests/gateway_client.rs b/crates/product-integration-tests/tests/gateway_client.rs deleted file mode 100644 index 0385eac7f..000000000 --- a/crates/product-integration-tests/tests/gateway_client.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! End-to-end checks that the model client and Gateway agree on their wire contract. - -#![expect( - clippy::unwrap_used, - clippy::expect_used, - reason = "test setup failures should stop the test immediately" -)] - -use std::net::SocketAddr; -use std::time::Duration; - -use axum::response::IntoResponse as _; -use axum::routing::post; -use axum::{Json, Router}; -use gateway::{Config, Gateway, ProfilesContext}; -use promptforge_model_client::client::{ - CompletionResult, GatewayClient, GatewayEndpoint, Message, SecretString, -}; -use promptforge_model_client::model::CompletionOptions; -use serde_json::Value; -use tokio::net::TcpListener; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; - -const PHASE_TIMEOUT: Duration = Duration::from_secs(10); -const SCENARIO_MODEL_URL: &str = - "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf?download=true"; -const SCENARIO_MODEL_SHA256: &str = - "9465e63a22add5354d9bb4b99e90117043c7124007664907259bd16d043bb031"; - -struct TestServer { - addr: SocketAddr, - shutdown: Option>, - handle: Option>>, -} - -impl TestServer { - async fn start(gateway: Gateway) -> TestServer { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let (shutdown, stopped) = oneshot::channel(); - let handle = tokio::spawn(async move { - gateway - .serve(listener, async { - let _ = stopped.await; - }) - .await - }); - TestServer { - addr, - shutdown: Some(shutdown), - handle: Some(handle), - } - } - - async fn shutdown(mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(handle) = self.handle.take() { - tokio::time::timeout(PHASE_TIMEOUT, handle) - .await - .expect("Gateway shutdown timed out") - .expect("Gateway task panicked") - .expect("Gateway serve failed"); - } - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(handle) = self.handle.take() { - handle.abort(); - } - } -} - -fn sse_reply(model: &str) -> String { - let chunk = |delta: Value, finish_reason: Value| { - serde_json::json!({ - "id": "chatcmpl-test", - "object": "chat.completion.chunk", - "model": model, - "choices": [{ - "index": 0, - "delta": delta, - "finish_reason": finish_reason - }] - }) - }; - let events = [ - chunk(serde_json::json!({ "content": "po" }), Value::Null), - chunk(serde_json::json!({ "content": "ng" }), Value::Null), - chunk(serde_json::json!({}), Value::String("stop".to_owned())), - ]; - let mut body = String::new(); - for event in events { - body.push_str("data: "); - body.push_str(&event.to_string()); - body.push_str("\n\n"); - } - body.push_str("data: [DONE]\n\n"); - body -} - -async fn fake_backend() -> SocketAddr { - async fn completions(Json(body): Json) -> axum::response::Response { - let model = body.get("model").and_then(Value::as_str).unwrap_or(""); - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - sse_reply(model), - ) - .into_response() - } - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let _ = axum::serve( - listener, - Router::new().route("/chat/completions", post(completions)), - ) - .await; - }); - addr -} - -async fn complete(server: &TestServer, model: &str, prompt: &str) -> String { - let client = GatewayClient::new( - GatewayEndpoint::new(&format!("http://{}/v1", server.addr)).expect("valid test endpoint"), - SecretString::new("test-token").expect("non-empty test key"), - ); - let options = CompletionOptions::new(model); - let completion = tokio::time::timeout( - PHASE_TIMEOUT, - client.complete(&[Message::user(prompt)], None, &options, |_delta| {}), - ) - .await - .expect("client completion timed out") - .expect("client completion failed"); - match completion.result() { - CompletionResult::Text(reply) => reply.to_owned(), - other => panic!("expected text reply, got {other:?}"), - } -} - -#[tokio::test] -async fn real_model_client_completes_through_gateway() { - let backend = fake_backend().await; - let config = Config::from_toml_str(&format!( - "config-version = 2\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ - [[endpoint]]\nid = \"fake\"\nprotocol = \"openai\"\n\ - base_url = \"http://{backend}\"\napi_key = \"\"\n\ - [[model]]\nname = \"test-model\"\ndescription = \"test\"\n\ - context = 8192\nupstream = \"backend-model\"\nendpoints = [\"fake\"]\n" - )) - .expect("Gateway config parses"); - let gateway = - Gateway::from_config(&config, ProfilesContext::default()).expect("Gateway assembles"); - let server = TestServer::start(gateway).await; - - assert_eq!(complete(&server, "test-model", "ping").await, "pong"); - server.shutdown().await; -} - -#[tokio::test] -#[ignore = "downloads llama-server and Qwen3-0.6B; set PROMPTFORGE_LIVE_LOCAL=1 to opt in"] -async fn real_model_client_completes_through_local_gateway() { - if std::env::var_os("PROMPTFORGE_LIVE_LOCAL").is_none() { - eprintln!("skipping: set PROMPTFORGE_LIVE_LOCAL=1 to run this test"); - return; - } - - let cache = tempfile::tempdir().unwrap(); - let config = Config::from_toml_str(&format!( - r#" -config-version = 2 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" - -[local] -cache_dir = "{cache}" - -[[local_model]] -name = "qwen-tiny" -description = "A careful analysis model suited to structured reasoning and long-context review" -source = "{source}" -sha256 = "{sha}" -context = 4096 -thinking = "never" -gpu_layers = 0 -flash_attention = false -n_predict = 64 -"#, - cache = cache.path().display().to_string().replace('\\', "/"), - source = SCENARIO_MODEL_URL, - sha = SCENARIO_MODEL_SHA256, - )) - .expect("local Gateway config parses"); - let gateway = tokio::task::spawn_blocking(move || { - Gateway::from_config(&config, ProfilesContext::default()) - }) - .await - .expect("local Gateway assembly task joins") - .expect("local Gateway assembles"); - let server = TestServer::start(gateway).await; - - let reply = complete( - &server, - "qwen-tiny", - "Reply with exactly the word pong and nothing else.", - ) - .await; - let normalized: String = reply - .chars() - .filter(|character| character.is_alphanumeric()) - .collect::() - .to_lowercase(); - assert_eq!(normalized, "pong", "expected pong, got {reply:?}"); - server.shutdown().await; -} diff --git a/crates/promptforge-agent/AGENTS.md b/crates/promptforge-agent/AGENTS.md deleted file mode 100644 index 7e72e24c8..000000000 --- a/crates/promptforge-agent/AGENTS.md +++ /dev/null @@ -1,8 +0,0 @@ -# promptforge-agent - -This crate is the PromptForge agent-program executor. - -- Core and this crate are sibling executors over the same substrate. Neither executor depends on the other. -- Absent, not stubbed: `call`, `fanout`, and `jump` do not exist in an agent VM. An agent touching them fails as an undefined global, exactly as a document prompt touching `models.chat` does. No courtesy stubs, no typed errors for absent calls. -- Tool calls go through the shared `promptforge_lua::dispatch_tool` body, never a duplicated dispatch loop. -- Every observer call uses the agent name as its stable `section` label. diff --git a/crates/promptforge-agent/Cargo.toml b/crates/promptforge-agent/Cargo.toml deleted file mode 100644 index d72e96954..000000000 --- a/crates/promptforge-agent/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "promptforge-agent" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "Promptforge library agent-program executor: run_agent drives .lua agent programs over the promptforge substrate" -documentation = "https://cppalliance.github.io/promptforge/" - -[dependencies] -mlua.workspace = true -promptforge-core-support.workspace = true -promptforge-lua.workspace = true -promptforge-model-client.workspace = true -promptforge-store.workspace = true -promptforge-tools.workspace = true -serde_json.workspace = true -shared-vfs.workspace = true -thiserror.workspace = true -tokio.workspace = true - -[dev-dependencies] -async-trait.workspace = true -axum.workspace = true -promptforge-vfs.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net"] } - -[lints] -workspace = true diff --git a/crates/promptforge-agent/src/agent.rs b/crates/promptforge-agent/src/agent.rs deleted file mode 100644 index b69c903df..000000000 --- a/crates/promptforge-agent/src/agent.rs +++ /dev/null @@ -1,1123 +0,0 @@ -//! `run_agent` and its leaf-dispatch driver. -//! -//! An agent program is one Lua chunk run as one coroutine on an agent VM - -//! a [`SectionVm`] built with the section construction sequence (harden, -//! untrusted, host injection, store, log, var) minus the section control -//! surface. The shared kernel is `models.infer` and `tools.call`; the -//! agent-only `models.chat`, `runtime.events()`, and `ui()` are installed -//! here and nowhere else; `call`, `fanout`, and `jump` are absent, not -//! stubbed, so touching them is an undefined-global failure. The driver -//! is leaf dispatch only: it resumes the coroutine, validates each yield -//! into a [`Request`], awaits exactly one future - the current request - -//! and resumes with the answer. Tool dispatch goes through the shared -//! [`dispatch_tool`] body; nothing here duplicates it. Before every -//! resume the driver republishes the -//! `runtime.events()` length bound ([`EventsSnapshot::refresh`]) - the -//! resume-refresh rule: appends land in the program's view only at -//! host-call resumes, never mid-chunk, so reads between suspensions stay -//! deterministic. -//! -//! `run_agent` installs [`AgentConfig::cancel`] as the task's cancel scope: -//! suspended host calls race cancellation, and running Lua observes the -//! same flag through the VM's instruction hook. Teardown is observed like a -//! section's, under the agent's name as the `section` label. - -use std::num::NonZeroU32; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::{Arc, Mutex}; - -use promptforge_core_support::cancel; -use promptforge_core_support::events::{CallMetrics, EventLog, ToolCallEvent}; -use promptforge_core_support::observe::{Observer, detail}; -use promptforge_core_support::untrusted::GuardNonce; -use promptforge_lua::{ - Answer, ChatResult, CoroStep, Error as LuaError, EventsSnapshot, LuaBlockResult, LuaProgram, - MessageRecord, Request, ScriptReport, SectionVm, ToolBinding, ToolCallCounts, ToolCallOutcome, - ToolOutputKind, ToolSet, YieldParse, current_tool_bindings, dispatch_tool, - install_agent_chat_shim, install_runtime_events, install_ui, project_messages, - resolve_model_binding, -}; -use promptforge_model_client::client::{ - Completion, CompletionResult, GatewayClient, Message, StreamDelta, ToolSchema, -}; -use promptforge_model_client::model::{ - ModelBinding, ModelCatalog, ModelInvocation, ModelSet, ModelView, -}; -use promptforge_store::Access; -use promptforge_tools::ToolCatalog; -use shared_vfs::{Origin, VfsRef}; - -use crate::config::AgentConfig; - -/// A type-erased owned error cause. -type BoxedSource = Box; - -/// The reason one agent run failed. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum AgentError { - /// The host fired the run's cancel handle ([`AgentConfig::cancel`]). - #[error("interrupted")] - Interrupted, - - /// The agent program failed: a Lua compile or runtime error, an - /// exhausted Lua resource quota, a failed host contract, a dispatch - /// failure the program did not catch, or the store capability's - /// acquisition failure before the program started. - #[error("{message}")] - Program { - /// The failure rendered as its location-tagged diagnostic. - message: String, - /// The originating typed error, kept as the cause when one exists. - #[source] - source: Option, - }, - - /// A model call failed in transport or protocol terms. - #[error("{message}")] - Model { - /// The completion failure's rendered message. - message: String, - /// The client's typed completion error, kept as the cause. - #[source] - source: BoxedSource, - }, - - /// An internal runtime invariant was violated (a state the surrounding - /// code has already guaranteed cannot occur). - #[error("internal invariant violated: {0}")] - Internal(&'static str), -} - -/// Maps the Lua substrate onto the agent's public error. Cancellation stays -/// typed; the source-bearing variants keep their causes; everything else - -/// authoring errors, quotas, host-contract failures - degrades to its -/// display string under [`AgentError::Program`], because an agent run has -/// no binding phase and cannot reach the substrate's resolution variants. -impl From for AgentError { - fn from(error: LuaError) -> AgentError { - match error { - LuaError::Interrupted => AgentError::Interrupted, - LuaError::LuaRuntime { message, source } | LuaError::Tool { message, source } => { - AgentError::Program { - message, - source: Some(source), - } - } - LuaError::LuaCompile { - location, - source_line, - message, - source, - .. - } => AgentError::Program { - message: format!( - "lua compilation error at {location} (line {source_line}): {message}" - ), - source: Some(source), - }, - other => AgentError::Program { - message: other.to_string(), - source: None, - }, - } - } -} - -/// Runs a `.lua` agent program in an agent VM. -/// -/// Agent-only host calls: `models.chat(messages, opts)` - one stateless -/// tool-capable model round, streaming its deltas to -/// [`AgentConfig::on_delta`] - `runtime.events()` - a read-only indexed -/// view over [`AgentConfig::event_log`] whose snapshot length bound -/// refreshes at every host-call resume - and `ui()` - a fresh host-state -/// snapshot per call from [`AgentConfig::ui`], nil as a global when the -/// host supplies no provider. Shared kernel: `tools.call`, `store`, `var`, -/// cancel checkpoints, `models.infer`. `call()`, `fanout()`, and -/// `jump()` do not exist here - absent, not stubbed. `run_agent` installs -/// `config.cancel` as the task's cancel scope, so every suspended host -/// call races cancellation through the shared dispatch. -/// -/// Every tool in `tools` is registered by its wire name with no semantic -/// resolution; every model in `models` is addressable by its catalog name -/// through `models.use` and `models.get`, with no prompt-wide default. The -/// gateway client resolves lazily from the environment on first inference, -/// the same fallback core's scheduler applies when a run supplies no -/// client. -/// -/// # Errors -/// Returns [`AgentError::Interrupted`] when `config.cancel` fires while the -/// program runs or a host call is suspended; [`AgentError::Program`] when -/// the program itself fails; [`AgentError::Model`] when a model call fails; -/// [`AgentError::Internal`] when a driver invariant is violated. -pub async fn run_agent( - source: &str, - tools: &ToolCatalog, - models: &ModelCatalog, - vfs: &VfsRef, - config: AgentConfig, -) -> Result<(), AgentError> { - run_agent_with_client(source, tools, models, vfs, config, None).await -} - -/// [`run_agent`] with an explicit gateway client instead of the lazy -/// environment resolution. -/// -/// This is the host-injection seam: a host that already knows its gateway -/// (the Workshop reads one from `workshop.toml`) passes the client it -/// built, so the agent's model calls reach the configured gateway rather -/// than whatever `PROMPTFORGE_GATEWAY_URL` names. `None` falls back to -/// the environment resolution, making this a strict superset of -/// [`run_agent`]. -/// -/// # Errors -/// Exactly [`run_agent`]'s: [`AgentError::Interrupted`] on a fired cancel -/// handle, [`AgentError::Program`] when the program fails, -/// [`AgentError::Model`] when a model call fails, and -/// [`AgentError::Internal`] on a violated driver invariant. -pub async fn run_agent_with_client( - source: &str, - tools: &ToolCatalog, - models: &ModelCatalog, - vfs: &VfsRef, - config: AgentConfig, - client: Option, -) -> Result<(), AgentError> { - let cancel = config.cancel.clone(); - cancel::scope(cancel, drive(source, tools, models, vfs, config, client)).await -} - -/// One agent run: compile, build the agent VM, drive the program coroutine -/// to its end, tear down. Runs inside the installed cancel scope. -async fn drive( - source: &str, - tools: &ToolCatalog, - models: &ModelCatalog, - vfs: &VfsRef, - config: AgentConfig, - client: Option, -) -> Result<(), AgentError> { - let AgentConfig { - name, - execution, - observer, - event_log, - on_delta, - ui, - limits, - .. - } = config; - let program = LuaProgram::compile( - source, - &format!("agent `{name}`"), - NonZeroU32::MIN, - &execution, - observer.as_ref(), - &name, - )?; - let tool_set = agent_tool_set(tools); - let model_set = agent_model_set(models); - let nonce = GuardNonce::fresh(); - let mut vm = SectionVm::new_for_section( - &nonce, - &tool_set, - &model_set, - &execution, - observer.as_ref(), - &name, - )?; - // A limits failure propagates bare, before any teardown observation - // exists - the section drivers' contract. - vm.apply_lua_limits(limits.lua_memory_bytes, limits.lua_log_events)?; - // The agent is one serial thread of execution: one capability for the - // whole run, released when it drops at the run's end. Its origin is - // the agent's own: the program is one chunk starting at its first - // line, and the agent's name is its source's name. - let access = Arc::new( - vfs.acquire(Origin::at(name.as_str(), name.as_str(), 1)) - .map_err(|error| AgentError::Program { - message: format!("the store capability acquisition failed: {error}"), - source: Some(Box::new(error)), - })?, - ); - let (counts, events) = - match setup_agent_vm(&mut vm, &access, &observer, &name, &tool_set, event_log, ui) { - Ok(installed) => installed, - Err(error) => { - vm.teardown(observer.as_ref(), &name); - return Err(error); - } - }; - let run = AgentRun { - vm: &vm, - program: &program, - tool_set: &tool_set, - model_view: Mutex::new(model_set), - counts, - events, - nonce: &nonce, - observer: &observer, - execution: &execution, - name: &name, - turns: AtomicU32::new(0), - client: Mutex::new(client), - on_delta, - }; - // The whole agent program is one chunk; the driver owns its observation - // boundaries, exactly as core's scheduler owns a block's. - observer.observe(&execution, &name, detail::LUA_CHUNK_STARTED); - let result = drive_program(&run).await; - observer.observe( - &execution, - &name, - if result.is_ok() { - detail::LUA_CHUNK_SUCCEEDED - } else { - detail::LUA_CHUNK_FAILED - }, - ); - vm.teardown(observer.as_ref(), &name); - result -} - -/// Registers every catalog tool by its wire name, with no semantic -/// resolution: one binding per tool, every alias in scope (the -/// `always` list), so `tools.call` reaches the whole catalog. Wire names are -/// assumed unique within one agent catalog; on a collision the first -/// binding wins alias lookup. A tool declaring -/// [`structured_output`](promptforge_tools::Tool::structured_output) binds -/// with the structured output kind, so its JSON output resumes into the -/// program as a table; everything else stays plain text. -fn agent_tool_set(catalog: &ToolCatalog) -> ToolSet { - let bindings: Vec = catalog - .tools() - .iter() - .map(|tool| ToolBinding { - alias: tool.wire_name().to_owned(), - description: tool.description().to_owned(), - id: tool.id(), - model_description: None, - tool: Arc::clone(tool), - conflicts: Vec::new(), - output_kind: if tool.structured_output() { - ToolOutputKind::Structured - } else { - ToolOutputKind::Plain - }, - }) - .collect(); - let always = bindings - .iter() - .map(|binding| binding.alias.clone()) - .collect(); - ToolSet::from_parts(bindings, always) -} - -/// Registers every catalog model by its catalog name: one binding per -/// descriptor with the default invocation (no temperature, token, or -/// thinking overrides) and no prompt-wide default, so a bare `models.infer` -/// requires a prior `models.use` selection. -fn agent_model_set(catalog: &ModelCatalog) -> ModelSet { - ModelSet { - bindings: catalog - .models() - .iter() - .map(|descriptor| { - ModelBinding::new( - descriptor.id().name(), - descriptor.description(), - descriptor.id().clone(), - ModelInvocation { - temperature: None, - max_tokens: None, - thinking: None, - }, - descriptor.context(), - ) - }) - .collect(), - default: None, - } -} - -/// The agent VM's setup sequence: the section construction reused (host -/// injection, host APIs, the coroutine shims) minus the section control -/// surface, plus the agent-only installs - the `models.chat` shim, the -/// read-only `tools.calls` counter surface over the run's dispatch counts, -/// the `runtime.events()` view over the host's [`EventLog`], and the -/// `ui()` snapshot function when the host supplied a provider (each call -/// invokes the provider and converts a fresh snapshot table, JSON nulls -/// reading as nil; no provider means no `ui` global at all). Returns the -/// counts the dispatches increment and, when a log was supplied, the -/// driver's [`EventsSnapshot`] refresh handle. -/// -/// Absent, not stubbed: the shared shim prelude installs `call` and -/// `fanout` for section VMs, but the agent kernel is `models.infer` and -/// `tools.call` alone, so both globals are removed here, before any author -/// code runs - an agent touching them fails as an undefined global. `jump` -/// is never installed at all: the scheduler control-global install is -/// skipped outright. `models.chat` is the mirror image: installed here and -/// never in a section VM. -fn setup_agent_vm( - vm: &mut SectionVm, - access: &Arc, - observer: &Arc, - name: &str, - tool_set: &ToolSet, - event_log: Option>, - ui: Option serde_json::Value + Send + Sync>>, -) -> Result<(ToolCallCounts, Option), AgentError> { - vm.inject_host_with_var("", &serde_json::json!({}), access, None)?; - vm.install_host_apis(observer, name)?; - vm.install_coro_shims()?; - install_agent_chat_shim(vm.lua())?; - let counts = vm.install_tool_call_counts(tool_set.bindings())?; - let events = install_runtime_events(vm.lua(), event_log)?; - let globals = vm.lua().globals(); - if let Some(provider) = ui { - install_ui(vm.lua(), provider).map_err(|error| AgentError::Program { - message: "installing the `ui` host call in the agent VM failed".to_owned(), - source: Some(Box::new(error)), - })?; - } - for global in ["call", "fanout"] { - globals - .raw_set(global, mlua::Value::Nil) - .map_err(|error| AgentError::Program { - message: format!("removing the `{global}` shim from the agent VM failed"), - source: Some(Box::new(error)), - })?; - } - Ok((counts, events)) -} - -/// The borrowed run pieces every driver step reads. -struct AgentRun<'a> { - /// The agent VM the program coroutine runs on. - vm: &'a SectionVm, - /// The compiled agent program. - program: &'a LuaProgram, - /// The frozen tool bindings (`tools.call`'s scope). - tool_set: &'a ToolSet, - /// The frozen model bindings behind `models.use`/`models.get`, read - /// through the `ModelView` impl on the mutex. - model_view: Mutex, - /// Per-alias dispatch counts, seeded with every catalog alias and read - /// back by the program through the `tools.calls` table. - counts: ToolCallCounts, - /// The driver side of the `runtime.events()` view, when the host - /// supplied an [`EventLog`]: its length bound is refreshed at every - /// host-call resume. - events: Option, - /// The run's untrusted-wrap nonce. - nonce: &'a GuardNonce, - /// The run's reporting sink. - observer: &'a Arc, - /// The run's execution id. - execution: &'a str, - /// The agent's name, every observer call's `section` label. - name: &'a str, - /// Completed model turns, reported on tool dispatches. - turns: AtomicU32, - /// The gateway client slot: the injected client, else resolved once - /// from the environment on first inference. Locked briefly and never - /// across an await. - client: Mutex>, - /// The host's live streaming-delta callback; `models.chat` forwards - /// every [`StreamDelta`] to it. Deltas never ride the observer. - on_delta: Option>, -} - -impl AgentRun<'_> { - /// The run's gateway client: the slot's, resolved once from the - /// environment when the caller injected none - the same lazy fallback - /// core's scheduler applies. - fn client(&self) -> Result { - let mut slot = self - .client - .lock() - .map_err(|_| AgentError::Internal("the agent client slot was poisoned"))?; - if let Some(client) = slot.as_ref() { - return Ok(client.clone()); - } - let client = GatewayClient::from_env().map_err(|error| AgentError::Model { - message: error.to_string(), - source: Box::new(error), - })?; - *slot = Some(client.clone()); - Ok(client) - } - - /// Applies the resume-refresh rule: republishes the events snapshot's - /// length bound, so appends that landed while the program was - /// suspended - or synchronously from a host callback while it ran - - /// become visible exactly at the resume this call precedes. - fn refresh_events(&self) { - if let Some(events) = &self.events { - events.refresh(); - } - } -} - -/// Drives the program coroutine to its end: resume, validate the yield, -/// dispatch the one in-flight request, resume with the answer. -async fn drive_program(run: &AgentRun<'_>) -> Result<(), AgentError> { - let mut step = run.vm.start_block_coro(run.program)?; - loop { - match step { - // The program returned: the run is complete. A scalar return - // value has no consumer at this step; completion is the signal. - CoroStep::Done(LuaBlockResult::Returned(_)) => return Ok(()), - CoroStep::Done(LuaBlockResult::Jump(_)) => { - // Unreachable: the jump global is never installed in an - // agent VM, so no chunk can record a transfer. - return Err(AgentError::Internal( - "an agent VM cannot record a jump: the jump global is never installed", - )); - } - CoroStep::Yielded(thread, values) => { - step = match run.vm.request_from_yield(&values) { - YieldParse::Request(request) => { - let answer = dispatch(run, request).await?; - run.refresh_events(); - run.vm - .resume_block_coro_answer(run.program, &thread, answer)? - } - // An argument-validation failure is the call's answer: - // the shim raises it at the call site, so an author - // `pcall` catches it. - YieldParse::Call(answer) => { - run.refresh_events(); - run.vm.resume_block_coro_answer( - run.program, - &thread, - answer.map_error(AgentError::from), - )? - } - YieldParse::Malformed(error) => return Err(error.into()), - }; - } - } - } -} - -/// Dispatches one validated request: the leaf calls the kernel installs, -/// plus unreachable internal-invariant guards for the section-only -/// requests, mirroring core's guard for the agent-only ones. -/// -/// A dispatch failure rides back as the call's answer so the program can -/// `pcall` it; cancellation alone fails the run instead of resuming. -async fn dispatch(run: &AgentRun<'_>, request: Request) -> Result, AgentError> { - match request { - Request::Infer { prompt, binding } => match dispatch_infer(run, &prompt, binding).await { - Err(AgentError::Interrupted) => Err(AgentError::Interrupted), - outcome => Ok(Answer::Infer(outcome)), - }, - Request::ToolCall { alias, args } => match dispatch_tool_call(run, &alias, args).await { - Err(AgentError::Interrupted) => Err(AgentError::Interrupted), - outcome => Ok(Answer::ToolCallResult(outcome)), - }, - Request::Chat { - messages, - model, - tools, - } => match dispatch_chat(run, &messages, model, &tools).await { - Err(AgentError::Interrupted) => Err(AgentError::Interrupted), - outcome => Ok(Answer::Chat(outcome.map(Box::new))), - }, - // Unreachable: the call/fanout shims are removed from the agent - // VM before author code runs, the models.loop and user_input - // shims are never installed on one, no shim produces an mcp - // request, and stripped coroutines make a hand-rolled yield fail - // validation before dispatch. - Request::Call { .. } => Err(AgentError::Internal( - "an agent VM cannot yield a call request: the shim is never installed", - )), - Request::Loop { .. } => Err(AgentError::Internal( - "an agent VM cannot yield a loop request: the models.loop shim is never installed", - )), - Request::UserInput => Err(AgentError::Internal( - "an agent VM cannot yield a user_input request: the shim is never installed", - )), - Request::Fanout { .. } => Err(AgentError::Internal( - "an agent VM cannot yield a fanout request: the shim is never installed", - )), - Request::Store { .. } => Err(AgentError::Internal( - "an agent VM cannot yield a store request: the store yield shims are never installed", - )), - Request::Mcp { .. } => Err(AgentError::Internal( - "an agent VM cannot yield an mcp request: no shim produces one", - )), - } -} - -/// One `models.infer` round: the handle's frozen binding or the program's -/// `models.use` selection, one direct tool-free gateway call on a fresh -/// conversation, raced against cancellation. Reported like a section's -/// infer round; an aborted round reports nothing, matching the scheduler's -/// abort path. -async fn dispatch_infer( - run: &AgentRun<'_>, - prompt: &str, - binding: Option, -) -> Result { - let binding = match binding { - Some(binding) => binding, - None => { - resolve_model_binding(&run.model_view, &run.vm.model_runtime)?.ok_or_else(|| { - AgentError::Program { - message: "no model is selected: call models.use(...) before models.infer" - .to_owned(), - source: None, - } - })? - } - }; - let client = run.client()?; - let options = binding.completion_options(); - let conversation = [Message::user(prompt)]; - // The one future the driver awaits, raced against the installed cancel - // scope so a suspended infer cannot hold the run past a cancel. A - // nested infer round consumes only the accumulated completion; live - // deltas have no consumer here. - let completion = tokio::select! { - biased; - () = cancel::wait_cancelled() => return Err(AgentError::Interrupted), - completion = client.complete(&conversation, None, &options, |_| {}) => completion, - }; - let completion = match completion { - Ok(completion) => completion, - Err(error) => { - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_FAILED); - return Err(AgentError::Model { - message: error.to_string(), - source: Box::new(error), - }); - } - }; - run.turns.fetch_add(1, Ordering::Relaxed); - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_COMPLETED); - match completion.result { - CompletionResult::Text(text) => { - if completion.finish_reason.as_deref() == Some("length") { - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_TRUNCATED); - } - Ok(text) - } - // No tools were advertised, so a tool-call turn is a backend - // protocol violation rather than something to dispatch. - CompletionResult::ToolCalls(_) => Err(AgentError::Program { - message: "model inference received tool calls but no tools were advertised".to_owned(), - source: None, - }), - // `CompletionResult` is `#[non_exhaustive]` across the crate seam: - // an unrecognized future outcome is the same violation. - _ => Err(AgentError::Program { - message: - "model inference received an unrecognized outcome but no tools were advertised" - .to_owned(), - source: None, - }), - } -} - -/// One `models.chat` round: one stateless tool-capable gateway call over -/// the program-built message list, streaming deltas to the host's -/// callback, raced against cancellation. -/// -/// The binding is `opts.model` (a catalog model name) or the program's -/// `models.use` selection; the advertised tools are exactly `opts.tools` -/// (default none - the driver adds nothing, so a host-primitive tool is -/// never advertised). A completed round fires `on_thinking` when the model -/// thought, then `on_assistant_reply` or `on_assistant_tool_calls`, each -/// with model and metrics; requested tool calls resume unexecuted - -/// dispatching them is the program's decision, taken on their presence, -/// never on `finish_reason`. The model client fails the batch when -/// `length` or `content_filter` truncates a tool-call round, and that -/// failure rides back as this call's answer. -/// A binding that is absent when dispatch begins, or a message list that -/// fails projection, reports a failed turn before its call-site error -/// resumes into Lua, so a surrounding `pcall` cannot hide the -/// operator-visible boundary failure. -async fn dispatch_chat( - run: &AgentRun<'_>, - messages: &[MessageRecord], - model: Option, - tools: &[String], -) -> Result { - let missing_binding = |message: String| { - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_FAILED); - AgentError::Program { - message, - source: None, - } - }; - let binding = match model { - Some(name) => ModelView::binding(&run.model_view, &name) - .map_err(|error| AgentError::Program { - message: error.to_string(), - source: Some(Box::new(error)), - })? - .ok_or_else(|| { - missing_binding(format!("model {name:?} is not in this agent's catalog")) - })?, - None => { - resolve_model_binding(&run.model_view, &run.vm.model_runtime)?.ok_or_else(|| { - missing_binding( - "no model is selected: pass opts.model or call models.use(...) \ - before models.chat" - .to_owned(), - ) - })? - } - }; - let schemas = advertised_schemas(run, tools)?; - // The per-dispatch projection: cross-record validation and provider - // shaping run here, immediately before the call, over the list as the - // program holds it now. A projection failure reports a failed turn - // before its call-site error resumes into Lua, exactly like the - // missing-binding path above. - let conversation = project_messages(messages).map_err(|error| { - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_FAILED); - AgentError::from(error) - })?; - let client = run.client()?; - let options = binding.completion_options(); - let tool_arg = if schemas.is_empty() { - None - } else { - Some(schemas.as_slice()) - }; - // The one future the driver awaits, raced against the installed cancel - // scope. Deltas forward live to the host's callback as they stream; - // they never enter the observer. - let completion = tokio::select! { - biased; - () = cancel::wait_cancelled() => return Err(AgentError::Interrupted), - completion = client.complete(&conversation, tool_arg, &options, |delta| { - if let Some(on_delta) = &run.on_delta { - on_delta(delta); - } - }) => completion, - }; - let completion = match completion { - Ok(completion) => completion, - Err(error) => { - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_FAILED); - return Err(AgentError::Model { - message: error.to_string(), - source: Box::new(error), - }); - } - }; - chat_round_result(run, completion) -} - -/// Applies one completed chat round: counts the turn, reports the -/// operational boundary, fires the content events - thinking first, then -/// the reply or the unexecuted tool-call batch, each with model and -/// metrics - and shapes the [`ChatResult`] the program resumes with. -fn chat_round_result(run: &AgentRun<'_>, completion: Completion) -> Result { - let turn = run.turns.fetch_add(1, Ordering::Relaxed) + 1; - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_COMPLETED); - let metrics = call_metrics(&completion); - let model_name = completion.model().to_owned(); - if let Some(thinking) = completion - .reasoning_content() - .filter(|text| !text.is_empty()) - { - run.observer - .on_thinking(run.execution, run.name, 0, 0, turn, &model_name, thinking); - } - let finish_reason = completion.finish_reason().map(str::to_owned); - match completion.result { - CompletionResult::Text(text) => { - if finish_reason.as_deref() == Some("length") { - run.observer - .observe(run.execution, run.name, detail::MODEL_TURN_TRUNCATED); - } - run.observer.on_assistant_reply( - run.execution, - run.name, - 0, - 0, - turn, - &text, - finish_reason.as_deref(), - &model_name, - metrics.as_ref(), - ); - Ok(ChatResult { - reply: Some(text), - tool_calls: None, - finish_reason, - model: model_name, - metrics, - }) - } - CompletionResult::ToolCalls(calls) => { - let events: Vec = calls - .into_iter() - .map(|call| ToolCallEvent { - id: call.id, - name: call.name, - arguments: call.arguments, - }) - .collect(); - run.observer.on_assistant_tool_calls( - run.execution, - run.name, - 0, - 0, - turn, - &model_name, - &events, - ); - Ok(ChatResult { - reply: None, - tool_calls: Some(events), - finish_reason, - model: model_name, - metrics, - }) - } - // `CompletionResult` is `#[non_exhaustive]` across the crate seam: - // an unrecognized future outcome cannot be resumed into the program. - _ => Err(AgentError::Program { - message: "model chat received an unrecognized completion outcome".to_owned(), - source: None, - }), - } -} - -/// Builds the advertised tool schemas for one chat round: exactly the -/// `opts.tools` aliases, resolved against the agent's effective scope, in -/// the author's order. The driver never adds to the set. -fn advertised_schemas(run: &AgentRun<'_>, tools: &[String]) -> Result, AgentError> { - if tools.is_empty() { - return Ok(Vec::new()); - } - let effective = current_tool_bindings(run.tool_set, &run.vm.tool_runtime)?; - let mut schemas = Vec::with_capacity(tools.len()); - for alias in tools { - let Some(binding) = effective.iter().find(|binding| binding.alias() == alias) else { - let in_scope: Vec<&str> = effective.iter().map(ToolBinding::alias).collect(); - return Err(AgentError::Program { - message: format!( - "tool alias {alias:?} is not registered with this agent; in scope: {in_scope:?}" - ), - source: None, - }); - }; - let description = binding - .model_description() - .unwrap_or_else(|| binding.tool().description()) - .to_owned(); - let schema = ToolSchema::new( - binding.alias().to_owned(), - description, - binding.tool().parameters_schema(), - ) - .map_err(|error| AgentError::Program { - message: format!("tool alias {alias:?} cannot be advertised to the model"), - source: Some(Box::new(error)), - })?; - schemas.push(schema); - } - Ok(schemas) -} - -/// Assembles the round's [`CallMetrics`] from everything the completion -/// measured, or `None` when nothing was measured. -fn call_metrics(completion: &Completion) -> Option { - let metrics = CallMetrics { - usage: completion.usage().cloned(), - llama: completion.llama_timings().cloned(), - vllm: completion.vllm_metrics().cloned(), - client: completion.client_timing().cloned(), - }; - let measured = metrics.usage.is_some() - || metrics.llama.is_some() - || metrics.vllm.is_some() - || metrics.client.is_some(); - measured.then_some(metrics) -} - -/// One `tools.call` dispatch: the alias resolved against the agent's -/// registered catalog, then the shared [`dispatch_tool`] body (cancel race, -/// counts, untrusted wrap, observer events), classified by the binding's -/// declared output kind. -async fn dispatch_tool_call( - run: &AgentRun<'_>, - alias: &str, - args: serde_json::Value, -) -> Result { - let effective = current_tool_bindings(run.tool_set, &run.vm.tool_runtime)?; - let Some(binding) = effective - .iter() - .find(|binding| binding.alias() == alias) - .cloned() - else { - let in_scope: Vec<&str> = effective.iter().map(ToolBinding::alias).collect(); - return Err(AgentError::Program { - message: format!( - "tool alias {alias:?} is not registered with this agent; in scope: {in_scope:?}" - ), - source: None, - }); - }; - // Agents have no fanout chains or call nesting: chain 0, depth 0. - let report = ScriptReport { - chain_id: 0, - depth: 0, - turn: run.turns.load(Ordering::Relaxed), - }; - let outcome = dispatch_tool( - &binding, - args, - Some(&run.counts), - run.nonce, - run.observer.as_ref(), - run.execution, - run.name, - Some(report), - ) - .await?; - ToolCallOutcome::from_dispatch(binding.output_kind, binding.alias(), outcome.into_content()) - .map_err(AgentError::from) -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroU32; - - use promptforge_core_support::cancel::CancelHandle; - use promptforge_core_support::observe::NullObserver; - use promptforge_model_client::client::{GatewayEndpoint, SecretString}; - use promptforge_model_client::model::{ModelDescriptor, ModelId, ThinkingMode}; - - use super::*; - use crate::config::AgentLimits; - - const EXECUTION: &str = "agent-test"; - - /// Reads one store file through a fresh, immediately dropped access: - /// the run's identity dropped with it, so nothing it wrote conflicts. - fn read_store( - vfs: &VfsRef, - path: &str, - ) -> std::result::Result { - let access = vfs - .acquire(Origin::new("read_store")) - .map_err(promptforge_store::StoreError::backend)?; - promptforge_store::StoreExt::store(vfs, &access).read(path) - } - - fn config() -> AgentConfig { - AgentConfig { - name: "test-agent".to_owned(), - execution: EXECUTION.to_owned(), - observer: Arc::new(NullObserver::default()), - cancel: CancelHandle::new(), - event_log: None, - on_delta: None, - ui: None, - limits: AgentLimits::default(), - } - } - - fn empty_tools() -> ToolCatalog { - ToolCatalog::new(&[]).expect("an empty catalog is valid") - } - - #[tokio::test] - async fn a_trivial_agent_writes_to_the_store_and_returns() { - let vfs = promptforge_vfs::empty(); - run_agent( - "store.write('notes.txt', 'from the agent')\nreturn 'done'", - &empty_tools(), - &ModelCatalog::empty(), - &vfs, - config(), - ) - .await - .expect("the trivial agent runs to completion"); - assert_eq!( - read_store(&vfs, "notes.txt").expect("the agent's write persists"), - "from the agent", - "the agent's store write must be visible through the run-scoped handle" - ); - } - - #[tokio::test] - async fn the_control_globals_are_nil_in_the_agent_vm() { - // Absent, not stubbed: a stub function would tostring as - // `function: 0x...`; only true absence renders three nils. - let vfs = promptforge_vfs::empty(); - let error = run_agent( - "return tostring(call) .. ' ' .. tostring(fanout) .. ' ' .. tostring(jump)", - &empty_tools(), - &ModelCatalog::empty(), - &vfs, - config(), - ) - .await; - assert!( - error.is_ok(), - "reading the absent globals is not an error: {error:?}" - ); - // The scalar return is not surfaced by run_agent; prove nil-ness - // through the store instead. - run_agent( - "store.write('nils.txt', tostring(call) .. ' ' .. tostring(fanout) .. ' ' .. tostring(jump))", - &empty_tools(), - &ModelCatalog::empty(), - &vfs, - config(), - ) - .await - .expect("the probe agent runs"); - assert_eq!( - read_store(&vfs, "nils.txt").expect("the probe wrote its reading"), - "nil nil nil", - "call, fanout, and jump must all be nil in the agent VM" - ); - } - - #[tokio::test] - async fn calling_an_absent_control_global_is_an_undefined_global_failure() { - for global in ["call", "fanout", "jump"] { - let vfs = promptforge_vfs::empty(); - let source = format!("{global}('anything')"); - let error = run_agent( - &source, - &empty_tools(), - &ModelCatalog::empty(), - &vfs, - config(), - ) - .await - .expect_err("calling an absent control global must fail the run"); - let message = error.to_string(); - assert!( - message.contains("attempt to call a nil value") && message.contains(global), - "`{global}` must fail as an undefined global, got: {message}" - ); - assert!( - matches!(error, AgentError::Program { .. }), - "an absent-global failure is a plain program error, never a typed variant: {error:?}" - ); - } - } - - #[tokio::test] - async fn ui_snapshots_are_fresh_per_call_and_json_nulls_read_nil() { - let vfs = promptforge_vfs::empty(); - let calls = Arc::new(AtomicU32::new(0)); - let counter = Arc::clone(&calls); - let mut run_config = config(); - run_config.ui = Some(Arc::new(move || { - let call = counter.fetch_add(1, Ordering::Relaxed) + 1; - serde_json::json!({ - "selected_model": format!("m{call}"), - "workspace_root": serde_json::Value::Null, - }) - })); - run_agent( - "local first = ui().selected_model\n\ - local second = ui().selected_model\n\ - local root = tostring(ui().workspace_root)\n\ - store.write('ui.txt', first .. '|' .. second .. '|' .. root)", - &empty_tools(), - &ModelCatalog::empty(), - &vfs, - run_config, - ) - .await - .expect("the ui probe agent runs"); - assert_eq!( - read_store(&vfs, "ui.txt").expect("the probe wrote its readings"), - "m1|m2|nil", - "every ui() call invokes the provider afresh, and a JSON null field reads nil" - ); - assert_eq!( - calls.load(Ordering::Relaxed), - 3, - "three ui() calls mean three provider invocations: no caching, no staleness" - ); - } - - #[tokio::test] - async fn ui_is_nil_without_a_provider() { - let vfs = promptforge_vfs::empty(); - run_agent( - "store.write('ui.txt', tostring(ui))", - &empty_tools(), - &ModelCatalog::empty(), - &vfs, - config(), - ) - .await - .expect("the probe agent runs"); - assert_eq!( - read_store(&vfs, "ui.txt").expect("the probe wrote its reading"), - "nil", - "no provider means no ui global at all - absent, not stubbed" - ); - } - - #[tokio::test] - async fn firing_cancel_interrupts_a_suspended_models_infer() { - // A gateway that accepts the connection and never answers, so only - // cancellation can end the round. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("an ephemeral listener binds"); - let addr = listener.local_addr().expect("the listener has an address"); - let endpoint = GatewayEndpoint::new(&format!("http://{addr}/v1")) - .expect("the test endpoint is a valid URL"); - let key = SecretString::new("test-key").expect("the test key is non-empty"); - let client = GatewayClient::new(endpoint, key); - let cancel = CancelHandle::new(); - let fire = cancel.clone(); - let mut run_config = config(); - run_config.cancel = cancel; - let context = NonZeroU32::new(4096).expect("4096 is non-zero"); - let models = ModelCatalog::new([ModelDescriptor::new( - ModelId::gateway("test-model").expect("the test model name is valid"), - "a test model", - context, - ThinkingMode::Never, - )]) - .expect("the test catalog has one unique model"); - let run = tokio::spawn(async move { - let vfs = promptforge_vfs::empty(); - run_agent_with_client( - "models.use('test-model')\nreturn models.infer('hello')", - &empty_tools(), - &models, - &vfs, - run_config, - Some(client), - ) - .await - }); - // The agent is suspended on models.infer once its request connects; - // the accepted socket is held open unanswered until the cancel - // fires, so the round cannot end any other way. - let (_socket, _) = listener.accept().await.expect("the infer request connects"); - fire.cancel(); - let result = run.await.expect("the run task joins"); - assert!( - matches!(result, Err(AgentError::Interrupted)), - "a cancelled suspended infer must interrupt the run, got {result:?}" - ); - } -} diff --git a/crates/promptforge-agent/src/config.rs b/crates/promptforge-agent/src/config.rs deleted file mode 100644 index d93759bcd..000000000 --- a/crates/promptforge-agent/src/config.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! The agent run's configuration: exactly what agents need. -//! -//! Core's `RunConfig` is untouched by the agent path; [`AgentConfig`] is the -//! slim agent counterpart, carried by value into `run_agent`. - -use std::sync::Arc; - -use promptforge_core_support::cancel::CancelHandle; -use promptforge_core_support::events::EventLog; -use promptforge_core_support::observe::Observer; -use promptforge_model_client::client::StreamDelta; - -/// Everything one `run_agent` call carries beyond its catalogs and store. -/// -/// How the caller learns things, by channel - `run_agent` itself signals -/// nothing beyond its return: -/// -/// - content events ride [`observer`](Self::observer); -/// - live deltas ride [`on_delta`](Self::on_delta); -/// - cancellation is the caller firing the [`cancel`](Self::cancel) handle -/// it retained, after which `run_agent` returns -/// [`Interrupted`](crate::AgentError::Interrupted). -pub struct AgentConfig { - /// The agent's name - its `.lua` file stem - passed as the `section` - /// label on every observer call, since agents have no sections. The - /// SPA and the event JSONL both key on it. - pub name: String, - /// The run's execution id, passed on every observer call. - pub execution: String, - /// The run's write-only reporting sink. - pub observer: Arc, - /// The run's cancel handle. `run_agent` installs it as the task's - /// cancel scope, so every suspended host call (`models.infer`, - /// `tools.call`) races cancellation and running Lua observes it through - /// the instruction hook. - pub cancel: CancelHandle, - /// The read-side history the agent builds context from, when the host - /// supplies one. Consumed by the agent-only `runtime.events()` host - /// call: a read-only indexed view whose snapshot length bound refreshes - /// at every host-call resume. Absent, `runtime.events()` returns an - /// empty table. - pub event_log: Option>, - /// The live streaming-delta callback, when the host supplies one. - /// Forwarded by the agent-only `models.chat`; deltas never ride the - /// observer. - pub on_delta: Option>, - /// The host-state snapshot provider behind the agent-only `ui()` host - /// call: every call invokes the provider and converts a fresh snapshot - /// table, JSON nulls reading as nil. Absent means `ui` is nil - the - /// global is not installed at all. - pub ui: Option serde_json::Value + Send + Sync>>, - /// Lua resource ceilings for the agent VM. - pub limits: AgentLimits, -} - -/// Shows the data fields; the trait objects and closures have no useful -/// rendering. -impl std::fmt::Debug for AgentConfig { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("AgentConfig") - .field("name", &self.name) - .field("execution", &self.execution) - .field("cancel", &self.cancel) - .field("event_log", &self.event_log.is_some()) - .field("on_delta", &self.on_delta.is_some()) - .field("ui", &self.ui.is_some()) - .field("limits", &self.limits) - .finish_non_exhaustive() - } -} - -/// Lua resource ceilings for one agent run. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AgentLimits { - /// The agent VM's Lua heap ceiling in bytes. - pub lua_memory_bytes: usize, - /// The agent VM's `log()` event budget. - pub lua_log_events: u32, -} - -impl Default for AgentLimits { - /// Mirrors the section VM's own defaults: a 64 MiB Lua heap and 1024 - /// `log()` events. - fn default() -> AgentLimits { - AgentLimits { - lua_memory_bytes: 64 * 1024 * 1024, - lua_log_events: 1024, - } - } -} diff --git a/crates/promptforge-agent/src/lib.rs b/crates/promptforge-agent/src/lib.rs deleted file mode 100644 index b1f5ccc7e..000000000 --- a/crates/promptforge-agent/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Agent-program executor for the Workshop. -//! -//! Document prompts (`.md`) run via `promptforge_core::execute::run`; agent -//! programs (`.lua`) run via [`run_agent`] in this crate. The two are -//! sibling executors over the same substrate (`promptforge-lua`, -//! `promptforge-model-client`, `promptforge-tools`, `promptforge-store`, -//! `promptforge-core-support`); neither depends on the other. -//! -//! An agent program is one long-running Lua chunk driven as a single -//! coroutine. Its host surface is the shared kernel - `models.infer`, -//! `tools.call`, `store`, `log`, `var`, and cooperative cancellation - plus -//! the agent-only calls: `models.chat(messages, opts)`, one stateless -//! tool-capable model round that streams deltas to the host and returns -//! the reply or the unexecuted tool calls, and `runtime.events()`, a -//! read-only indexed view over the host-supplied event log whose snapshot -//! refreshes at every host-call resume. `call()`, `fanout()`, and -//! `jump()` are absent - not stubbed - so an agent touching them fails as -//! an undefined global, exactly as a document prompt touching the -//! agent-only calls does. - -mod agent; -mod config; - -#[cfg(test)] -mod tests; - -pub use agent::{AgentError, run_agent, run_agent_with_client}; -pub use config::{AgentConfig, AgentLimits}; diff --git a/crates/promptforge-agent/src/tests.rs b/crates/promptforge-agent/src/tests.rs deleted file mode 100644 index e78c1b812..000000000 --- a/crates/promptforge-agent/src/tests.rs +++ /dev/null @@ -1,1515 +0,0 @@ -//! Agent-crate tests over an SSE fixture gateway. -//! -//! The gateway serves a fixed script of buffered chat-completion bodies, -//! each converted to the SSE chunk stream the always-streaming model client -//! consumes, and records every request body - so a test can pin both what -//! the driver sent (advertised tools, wire messages, model name) and what -//! the program received back. The conversion mirrors the executor-side -//! `ScriptedGateway` in `promptforge-core`; test fixtures cannot cross the -//! crate boundary, so the agent crate carries its own. - -use std::net::SocketAddr; -use std::num::NonZeroU32; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; - -use axum::Json; -use axum::Router; -use axum::extract::State; -use axum::routing::post; -use serde_json::{Value, json}; - -use promptforge_core_support::cancel::CancelHandle; -use promptforge_core_support::events::{ - CallMetrics, EventLog, RuntimeEvent, RuntimeEventKind, ToolCallEvent, -}; -use promptforge_core_support::observe::{Observation, Observer}; -use promptforge_model_client::client::{GatewayClient, GatewayEndpoint, SecretString, StreamDelta}; -use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; -use promptforge_store::StoreExt; -use promptforge_tools::{Tool, ToolCatalog, ToolError, ToolId, ToolOutput}; -use shared_vfs::{Origin, VfsRef}; - -use crate::agent::run_agent_with_client; -use crate::{AgentConfig, AgentError, AgentLimits, run_agent}; - -/// The execution id every fixture run reports under. -const EXECUTION: &str = "agent-chat-test"; - -/// The agent name every fixture run reports as its `section` label. -const AGENT_NAME: &str = "chat-agent"; - -/// Splits `text` at its char midpoint, so a scripted string streams as two -/// fragments and the client's accumulation is actually exercised. -fn split_for_stream(text: &str) -> (&str, &str) { - let mid = text.chars().count() / 2; - let at = text - .char_indices() - .nth(mid) - .map_or(text.len(), |(index, _)| index); - text.split_at(at) -} - -/// Converts one buffered chat-completion body into the SSE event text a -/// streaming backend would emit for it: reasoning deltas, content split -/// across fragments, tool calls as split argument fragments, the -/// finish-reason chunk, a trailing empty-choices summary chunk when the -/// body carries `usage`/`timings`/`metrics`, and the `[DONE]` sentinel. -fn sse_events(body: &Value) -> String { - let model = body.get("model").cloned(); - let choice = body["choices"].get(0).cloned().unwrap_or_default(); - let message = choice.get("message").cloned().unwrap_or_default(); - let chunk = |delta: Value, finish: Option<&Value>| -> Value { - let mut chunk_choice = json!({ "index": 0, "delta": delta }); - if let Some(finish) = finish { - chunk_choice["finish_reason"] = finish.clone(); - } - let mut event = json!({ "object": "chat.completion.chunk", "choices": [chunk_choice] }); - if let Some(model) = &model { - event["model"] = model.clone(); - } - event - }; - let mut events: Vec = Vec::new(); - if let Some(reasoning) = message.get("reasoning_content").and_then(Value::as_str) { - events.push(chunk(json!({ "reasoning_content": reasoning }), None)); - } - if let Some(content) = message.get("content").and_then(Value::as_str) { - let (first, second) = split_for_stream(content); - for part in [first, second] { - if !part.is_empty() { - events.push(chunk(json!({ "content": part }), None)); - } - } - } - if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) { - for (index, call) in calls.iter().enumerate() { - let arguments = call - .pointer("/function/arguments") - .and_then(Value::as_str) - .unwrap_or_default(); - let (first, second) = split_for_stream(arguments); - let mut opener = json!({ - "index": index, - "type": "function", - "function": { - "name": call.pointer("/function/name").cloned().unwrap_or(Value::Null), - "arguments": first, - }, - }); - if let Some(id) = call.get("id") { - opener["id"] = id.clone(); - } - events.push(chunk(json!({ "tool_calls": [opener] }), None)); - if !second.is_empty() { - events.push(chunk( - json!({ "tool_calls": [{ - "index": index, - "function": { "arguments": second }, - }] }), - None, - )); - } - } - } - let finish = choice.get("finish_reason").cloned().unwrap_or(Value::Null); - events.push(chunk(json!({}), Some(&finish))); - let mut summary = serde_json::Map::new(); - for key in ["usage", "timings", "metrics"] { - if let Some(section) = body.get(key).filter(|section| !section.is_null()) { - summary.insert(key.to_owned(), section.clone()); - } - } - if !summary.is_empty() { - let mut event = json!({ "object": "chat.completion.chunk", "choices": [] }); - if let Some(model) = &model { - event["model"] = model.clone(); - } - for (key, value) in summary { - event[key] = value; - } - events.push(event); - } - let mut out = String::new(); - for event in &events { - out.push_str("data: "); - out.push_str(&event.to_string()); - out.push_str("\n\n"); - } - out.push_str("data: [DONE]\n\n"); - out -} - -#[derive(Clone)] -struct FixtureState { - bodies: Arc>, - requests: Arc>>, - calls: Arc, -} - -/// The SSE fixture gateway: serves scripted completion bodies in order -/// (repeating the last), records every request body, and counts calls. -/// -/// The server is owned: the guard holds the bound address, a -/// graceful-shutdown sender, and the serving task's handle, so no detached -/// server survives the test. -struct FixtureGateway { - addr: SocketAddr, - requests: Arc>>, - calls: Arc, - shutdown: Option>, - server: tokio::task::JoinHandle<()>, -} - -impl FixtureGateway { - /// Starts a gateway serving `bodies` in order (repeating the last). - async fn start(bodies: Vec) -> FixtureGateway { - async fn completions( - State(state): State, - Json(body): Json, - ) -> axum::response::Response { - use axum::response::IntoResponse; - let n = state.calls.fetch_add(1, Ordering::SeqCst); - state - .requests - .lock() - .expect("the fixture request log must not be poisoned") - .push(body); - let index = n.min(state.bodies.len() - 1); - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - sse_events(&state.bodies[index]), - ) - .into_response() - } - - assert!( - !bodies.is_empty(), - "a fixture gateway needs at least one scripted body" - ); - let requests = Arc::new(Mutex::new(Vec::new())); - let calls = Arc::new(AtomicUsize::new(0)); - let state = FixtureState { - bodies: Arc::new(bodies), - requests: Arc::clone(&requests), - calls: Arc::clone(&calls), - }; - let router = Router::new() - .route("/v1/chat/completions", post(completions)) - .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("the fixture gateway must bind a local port"); - let addr = listener - .local_addr() - .expect("the fixture gateway must report its local address"); - let (shutdown, rx) = tokio::sync::oneshot::channel::<()>(); - let server = tokio::spawn(async move { - // The serve outcome is swallowed so a torn-down test runtime can - // never trigger a detached-task panic. - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { - let _ = rx.await; - }) - .await; - }); - FixtureGateway { - addr, - requests, - calls, - shutdown: Some(shutdown), - server, - } - } - - /// A snapshot of every recorded request body, in arrival order. - fn requests(&self) -> Vec { - self.requests - .lock() - .expect("the fixture request log must not be poisoned") - .clone() - } - - /// The number of completion requests served so far. - fn call_count(&self) -> usize { - self.calls.load(Ordering::SeqCst) - } -} - -impl Drop for FixtureGateway { - fn drop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - self.server.abort(); - } -} - -/// A minimal counting tool, registered under its wire name, trusted or -/// untrusted per construction: chat tests advertise it to prove the driver -/// never executes what a round returns, and dispatch tests call it to prove -/// the shared dispatch counts and wraps. -struct FixtureTool { - id: ToolId, - wire_name: &'static str, - trusted: bool, - calls: Arc, -} - -#[async_trait::async_trait] -impl Tool for FixtureTool { - fn id(&self) -> ToolId { - self.id.clone() - } - - fn wire_name(&self) -> &str { - self.wire_name - } - - fn description(&self) -> &'static str { - "A fixture tool that counts its calls." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - }) - } - - async fn call(&self, _args: Value) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - Ok(if self.trusted { - ToolOutput::trusted("fixture output") - } else { - ToolOutput::untrusted("fixture output") - }) - } -} - -/// Builds one fixture tool with the given trust and the counter that proves -/// whether it ran. -fn fixture_tool_with_trust( - wire_name: &'static str, - trusted: bool, -) -> (Arc, Arc) { - let calls = Arc::new(AtomicUsize::new(0)); - let tool = FixtureTool { - id: ToolId::new("fixture", wire_name).expect("the fixture tool id is valid"), - wire_name, - trusted, - calls: Arc::clone(&calls), - }; - (Arc::new(tool), calls) -} - -/// Builds one trusted fixture tool and the counter that proves whether it -/// ran. -fn fixture_tool(wire_name: &'static str) -> (Arc, Arc) { - fixture_tool_with_trust(wire_name, true) -} - -/// A tool that signals when its call starts and then never completes, so -/// only cancellation can end the dispatch. -struct BlockingTool { - id: ToolId, - started: Arc, -} - -#[async_trait::async_trait] -impl Tool for BlockingTool { - fn id(&self) -> ToolId { - self.id.clone() - } - - fn wire_name(&self) -> &'static str { - "blocking" - } - - fn description(&self) -> &'static str { - "A fixture tool that never completes." - } - - fn parameters_schema(&self) -> Value { - json!({ "type": "object" }) - } - - async fn call(&self, _args: Value) -> Result { - self.started.notify_one(); - std::future::pending().await - } -} - -/// An [`Observer`] + [`EventLog`] fixture, the workshop observer's test -/// stand-in: the program's own `log()` checkpoints append synchronously -/// while the chunk runs (the [`Observation::Lua`] arm), completed replies -/// append from the chat dispatch, and the log reads back through -/// `runtime.events()`. -#[derive(Default)] -struct FixtureEventLog { - events: Mutex>, -} - -impl FixtureEventLog { - fn push(&self, kind: RuntimeEventKind, content: &str, model: Option<&str>) { - self.events - .lock() - .expect("the fixture event log must not be poisoned") - .push(RuntimeEvent { - kind, - section: AGENT_NAME.to_owned(), - chain_id: 0, - depth: 0, - turn: 0, - content: content.to_owned(), - model: model.map(str::to_owned), - tool_call_id: None, - finish_reason: None, - metrics: None, - }); - } -} - -impl Observer for FixtureEventLog { - fn observe(&self, _execution: &str, _section: &str, event: Observation) { - // A `log()` checkpoint appends synchronously while the chunk runs - - // exactly the mid-chunk append the visibility test needs. - if let Observation::Lua(message) = event { - self.push(RuntimeEventKind::UserInput, &message, None); - } - } - - fn on_assistant_reply( - &self, - _execution: &str, - _section: &str, - _chain_id: u32, - _depth: u32, - _turn: u32, - text: &str, - _finish_reason: Option<&str>, - model: &str, - _metrics: Option<&CallMetrics>, - ) { - self.push(RuntimeEventKind::AssistantReply, text, Some(model)); - } -} - -impl EventLog for FixtureEventLog { - fn len(&self) -> u64 { - u64::try_from( - self.events - .lock() - .expect("the fixture event log must not be poisoned") - .len(), - ) - .expect("the fixture log length fits in u64") - } - - fn get(&self, index: u64) -> Option { - let events = self - .events - .lock() - .expect("the fixture event log must not be poisoned"); - usize::try_from(index) - .ok() - .and_then(|index| events.get(index).cloned()) - } -} - -/// The fixture model catalog: two catalog models so an `opts.model` -/// override is distinguishable from the `models.use` selection. -fn fixture_models() -> ModelCatalog { - let context = NonZeroU32::new(4096).expect("4096 is non-zero"); - ModelCatalog::new([ - ModelDescriptor::new( - ModelId::gateway("test-model").expect("the fixture model name is valid"), - "the default fixture model", - context, - ThinkingMode::Never, - ), - ModelDescriptor::new( - ModelId::gateway("other-model").expect("the fixture model name is valid"), - "the alternate fixture model", - context, - ThinkingMode::Never, - ), - ]) - .expect("the fixture catalog has unique models") -} - -/// One assistant reply recorded by the [`ContentRecorder`]. -struct RecordedReply { - section: String, - turn: u32, - text: String, - finish_reason: Option, - model: String, - has_metrics: bool, -} - -/// An [`Observer`] that keeps every content event it is handed, so a test -/// can assert each fires exactly once with its model attribution. -#[derive(Default)] -struct ContentRecorder { - observations: Mutex>, - replies: Mutex>, - batches: Mutex)>>, - thinking: Mutex>, -} - -impl Observer for ContentRecorder { - fn observe(&self, _execution: &str, _section: &str, event: Observation) { - self.observations - .lock() - .expect("the observation log must not be poisoned") - .push(event); - } - - fn on_assistant_reply( - &self, - execution: &str, - section: &str, - _chain_id: u32, - _depth: u32, - turn: u32, - text: &str, - finish_reason: Option<&str>, - model: &str, - metrics: Option<&promptforge_core_support::events::CallMetrics>, - ) { - assert_eq!(execution, EXECUTION); - self.replies - .lock() - .expect("the reply log must not be poisoned") - .push(RecordedReply { - section: section.to_owned(), - turn, - text: text.to_owned(), - finish_reason: finish_reason.map(str::to_owned), - model: model.to_owned(), - has_metrics: metrics.is_some(), - }); - } - - fn on_assistant_tool_calls( - &self, - _execution: &str, - _section: &str, - _chain_id: u32, - _depth: u32, - _turn: u32, - model: &str, - calls: &[ToolCallEvent], - ) { - self.batches - .lock() - .expect("the batch log must not be poisoned") - .push((model.to_owned(), calls.to_vec())); - } - - fn on_thinking( - &self, - _execution: &str, - _section: &str, - _chain_id: u32, - _depth: u32, - _turn: u32, - model: &str, - text: &str, - ) { - self.thinking - .lock() - .expect("the thinking log must not be poisoned") - .push((model.to_owned(), text.to_owned())); - } -} - -/// An [`AgentConfig`] pointed at `observer`, with the fixture run's fixed -/// name and execution id. -fn config_with(observer: Arc) -> AgentConfig { - AgentConfig { - name: AGENT_NAME.to_owned(), - execution: EXECUTION.to_owned(), - observer, - cancel: CancelHandle::new(), - event_log: None, - on_delta: None, - ui: None, - limits: AgentLimits::default(), - } -} - -/// A fixture config that discards every observation. -fn config() -> AgentConfig { - config_with(Arc::new( - promptforge_core_support::observe::NullObserver::default(), - )) -} - -/// One completed fixture run: the gateway (its recorded requests), the -/// run's VFS handle the program wrote its assertions into, and the run's -/// outcome. -struct FixtureRun { - gateway: FixtureGateway, - vfs: VfsRef, - result: Result<(), AgentError>, -} - -impl FixtureRun { - /// Reads one store file the agent program wrote, through a fresh, - /// immediately dropped access: the run's identity dropped with it, so - /// nothing it wrote conflicts with the extraction. - fn read(&self, path: &str) -> String { - let access = self - .vfs - .acquire(Origin::new("FixtureRun::read")) - .expect("the stock backend acquires"); - self.vfs - .store(&access) - .read(path) - .unwrap_or_else(|error| panic!("the program wrote {path}: {error}")) - } -} - -/// Runs `source` as an agent program against a fixture gateway scripted -/// with `bodies`, under the fixture model catalog. -async fn run_over_fixture( - source: &str, - bodies: Vec, - tools: ToolCatalog, - config: AgentConfig, -) -> FixtureRun { - let gateway = FixtureGateway::start(bodies).await; - let endpoint = GatewayEndpoint::new(&format!("http://{}/v1", gateway.addr)) - .expect("the fixture endpoint is a valid URL"); - let key = SecretString::new("fixture-key").expect("the fixture key is non-empty"); - let client = GatewayClient::new(endpoint, key); - let vfs = promptforge_vfs::empty(); - let result = run_agent_with_client( - source, - &tools, - &fixture_models(), - &vfs, - config, - Some(client), - ) - .await; - FixtureRun { - gateway, - vfs, - result, - } -} - -/// An empty tool catalog for runs that advertise nothing. -fn no_tools() -> ToolCatalog { - ToolCatalog::new(&[]).expect("an empty catalog is valid") -} - -/// A scripted body producing a plain text reply from `model`. -fn text_body(model: &str, content: &str, finish: &str) -> Value { - json!({ - "model": model, - "choices": [{ - "message": { "role": "assistant", "content": content }, - "finish_reason": finish, - }], - }) -} - -/// A scripted body producing one `echo` tool call with the given finish -/// reason. -fn tool_call_body(finish: &str) -> Value { - json!({ - "model": "fixture-model", - "choices": [{ - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": { "name": "echo", "arguments": "{\"value\":\"hi\"}" }, - }], - }, - "finish_reason": finish, - }], - }) -} - -#[tokio::test] -async fn a_chat_text_reply_round_trips_with_model_and_metrics() { - let body = json!({ - "model": "fixture-model", - "choices": [{ - "message": { "role": "assistant", "content": "Hello agent" }, - "finish_reason": "stop", - }], - "usage": { "prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10 }, - }); - let run = run_over_fixture( - r#" -models.use('test-model') -local result = models.chat({ { role = "user", content = "hi" } }) -store.write('reply.txt', result.reply) -store.write('model.txt', result.model) -store.write('finish.txt', result.finish_reason) -store.write('tools_nil.txt', tostring(result.tool_calls == nil)) -store.write('total.txt', tostring(result.metrics.usage.total_tokens)) -store.write('timed.txt', tostring(result.metrics.client.e2e_ms >= 0)) -"#, - vec![body], - no_tools(), - config(), - ) - .await; - run.result.as_ref().expect("the chat round completes"); - assert_eq!(run.read("reply.txt"), "Hello agent"); - assert_eq!(run.read("model.txt"), "fixture-model"); - assert_eq!(run.read("finish.txt"), "stop"); - assert_eq!( - run.read("tools_nil.txt"), - "true", - "a text round resumes with tool_calls nil, the presence branch" - ); - assert_eq!(run.read("total.txt"), "10"); - assert_eq!( - run.read("timed.txt"), - "true", - "client timing must reach the program's metrics table" - ); - // The no-tools default: the request carries no tools field at all. - let requests = run.gateway.requests(); - assert_eq!(requests.len(), 1); - assert!( - requests[0].get("tools").is_none(), - "opts.tools defaults to none: no tools field on the wire" - ); - assert_eq!(requests[0]["model"], json!("test-model")); -} - -#[tokio::test] -async fn chat_tool_calls_return_unexecuted() { - let (echo, echo_calls) = fixture_tool("echo"); - let tools = ToolCatalog::new(&[echo]).expect("the fixture catalog is valid"); - let run = run_over_fixture( - r#" -models.use('test-model') -local result = models.chat( - { { role = "user", content = "call the tool" } }, - { tools = { "echo" } } -) -store.write('reply_nil.txt', tostring(result.reply == nil)) -store.write( - 'call.txt', - result.tool_calls[1].id .. ' ' .. result.tool_calls[1].name - .. ' ' .. result.tool_calls[1].arguments.value -) -"#, - vec![tool_call_body("tool_calls")], - tools, - config(), - ) - .await; - run.result.as_ref().expect("the chat round completes"); - assert_eq!(run.read("reply_nil.txt"), "true"); - assert_eq!(run.read("call.txt"), "call_1 echo hi"); - assert_eq!( - echo_calls.load(Ordering::SeqCst), - 0, - "a chat round returns tool calls unexecuted; dispatch is the program's decision" - ); -} - -#[tokio::test] -async fn stop_with_tool_calls_still_surfaces_the_tool_calls() { - // llama.cpp and vLLM routinely finish tool-call rounds with "stop": - // presence, not finish_reason, is the signal the program branches on. - let (echo, _) = fixture_tool("echo"); - let tools = ToolCatalog::new(&[echo]).expect("the fixture catalog is valid"); - let run = run_over_fixture( - r#" -models.use('test-model') -local result = models.chat( - { { role = "user", content = "call the tool" } }, - { tools = { "echo" } } -) -store.write('present.txt', tostring(result.tool_calls ~= nil)) -store.write('reply_nil.txt', tostring(result.reply == nil)) -store.write('finish.txt', result.finish_reason) -"#, - vec![tool_call_body("stop")], - tools, - config(), - ) - .await; - run.result.as_ref().expect("the chat round completes"); - assert_eq!(run.read("present.txt"), "true"); - assert_eq!(run.read("reply_nil.txt"), "true"); - assert_eq!(run.read("finish.txt"), "stop"); -} - -#[tokio::test] -async fn opts_model_overrides_the_selection() { - let run = run_over_fixture( - r#" -models.use('test-model') -models.chat({ { role = "user", content = "hi" } }, { model = "other-model" }) -"#, - vec![text_body("fixture-model", "ok", "stop")], - no_tools(), - config(), - ) - .await; - run.result.as_ref().expect("the overridden round completes"); - assert_eq!( - run.gateway.requests()[0]["model"], - json!("other-model"), - "opts.model must override the models.use selection on the wire" - ); - - // With no selection at all, opts.model alone carries the round; without - // either, the call fails at the call site naming both paths. - let run = run_over_fixture( - r#" -models.chat({ { role = "user", content = "hi" } }, { model = "other-model" }) -local ok, err = pcall(function() - return models.chat({ { role = "user", content = "hi" } }) -end) -store.write('ok.txt', tostring(ok)) -store.write('err.txt', err) -"#, - vec![text_body("fixture-model", "ok", "stop")], - no_tools(), - config(), - ) - .await; - run.result - .as_ref() - .expect("the opts.model-only run completes"); - assert_eq!(run.read("ok.txt"), "false"); - assert!( - run.read("err.txt").contains("no model is selected"), - "a selection-free chat without opts.model names the fix" - ); - assert_eq!(run.gateway.call_count(), 1); -} - -#[tokio::test] -async fn a_missing_chat_binding_reports_one_failed_turn_before_lua_pcall_resumes() { - let recorder = Arc::new(ContentRecorder::default()); - let mut config = config_with(Arc::clone(&recorder) as Arc); - config.ui = Some(Arc::new( - || json!({ "selected_model": serde_json::Value::Null }), - )); - let run = run_over_fixture( - r#" -local ok, err = pcall(function() - return models.chat( - { { role = "user", content = "not sent" } }, - { model = ui().selected_model } - ) -end) -store.write('ok.txt', tostring(ok)) -store.write('err.txt', err) -"#, - vec![text_body("fixture-model", "never fetched", "stop")], - no_tools(), - config, - ) - .await; - run.result - .as_ref() - .expect("the program catches the missing binding"); - assert_eq!(run.read("ok.txt"), "false"); - assert!( - run.read("err.txt").contains("no model is selected"), - "the call-site error tells the program why no request ran: {}", - run.read("err.txt") - ); - assert_eq!( - run.gateway.call_count(), - 0, - "a missing binding fails before any live model request" - ); - let observations = recorder - .observations - .lock() - .expect("the observation log is intact"); - assert_eq!( - observations - .iter() - .filter(|event| matches!(event, Observation::ModelTurnFailed)) - .count(), - 1, - "the failed boundary is observed exactly once before pcall recovers" - ); -} - -#[tokio::test] -async fn a_failed_chat_projection_reports_one_failed_turn_before_lua_pcall_resumes() { - let recorder = Arc::new(ContentRecorder::default()); - let run = run_over_fixture( - r#" -models.use('test-model') -local ok, err = pcall(function() - return models.chat({ - { role = "user", content = "hi" }, - { role = "system", content = "late" }, - }) -end) -store.write('ok.txt', tostring(ok)) -store.write('err.txt', err) -"#, - vec![text_body("fixture-model", "never fetched", "stop")], - no_tools(), - config_with(Arc::clone(&recorder) as Arc), - ) - .await; - run.result - .as_ref() - .expect("the program catches the projection failure"); - assert_eq!(run.read("ok.txt"), "false"); - assert!( - run.read("err.txt") - .contains("system message outside the leading system block"), - "the call-site error names the projection violation: {}", - run.read("err.txt") - ); - assert_eq!( - run.gateway.call_count(), - 0, - "a projection failure fails before any live model request" - ); - let observations = recorder - .observations - .lock() - .expect("the observation log is intact"); - assert_eq!( - observations - .iter() - .filter(|event| matches!(event, Observation::ModelTurnFailed)) - .count(), - 1, - "the failed boundary is observed exactly once before pcall recovers" - ); -} - -#[tokio::test] -async fn opts_tools_control_the_advertised_set_and_default_to_none() { - let (echo, _) = fixture_tool("echo"); - let (search, _) = fixture_tool("search"); - let tools = ToolCatalog::new(&[echo, search]).expect("the fixture catalog is valid"); - let run = run_over_fixture( - r#" -models.use('test-model') -models.chat({ { role = "user", content = "one" } }) -models.chat({ { role = "user", content = "two" } }, { tools = { "echo" } }) -local ok, err = pcall(function() - return models.chat({ { role = "user", content = "three" } }, { tools = { "ghost" } }) -end) -store.write('unknown_ok.txt', tostring(ok)) -store.write('unknown_err.txt', err) -"#, - vec![text_body("fixture-model", "ok", "stop")], - tools, - config(), - ) - .await; - run.result - .as_ref() - .expect("the advertised-set run completes"); - let requests = run.gateway.requests(); - assert_eq!( - requests.len(), - 2, - "the unknown-alias round must fail before any request is sent" - ); - assert!( - requests[0].get("tools").is_none(), - "no opts.tools means no tools field: the default is none" - ); - let advertised: Vec<&str> = requests[1]["tools"] - .as_array() - .expect("the second round advertises tools") - .iter() - .map(|tool| { - tool.pointer("/function/name") - .and_then(Value::as_str) - .expect("each advertised tool names its function") - }) - .collect(); - assert_eq!( - advertised, - vec!["echo"], - "exactly the opts.tools aliases are advertised, nothing else from the catalog" - ); - assert_eq!(run.read("unknown_ok.txt"), "false"); - assert!( - run.read("unknown_err.txt") - .contains("is not registered with this agent"), - "an unknown alias fails the call naming the miss" - ); -} - -#[tokio::test] -async fn an_invalid_message_table_fails_at_the_call_site_naming_the_index() { - let run = run_over_fixture( - r#" -models.use('test-model') -local ok, err = pcall(function() - return models.chat({ - { role = "user", content = "ok" }, - { role = "wizard", content = "x" }, - }) -end) -store.write('ok.txt', tostring(ok)) -store.write('err.txt', err) -"#, - vec![text_body("fixture-model", "never fetched", "stop")], - no_tools(), - config(), - ) - .await; - run.result - .as_ref() - .expect("the program catches the call error"); - assert_eq!(run.read("ok.txt"), "false"); - assert!( - run.read("err.txt").contains("messages[2]"), - "the validation error names the offending index: {}", - run.read("err.txt") - ); - assert_eq!( - run.gateway.call_count(), - 0, - "an invalid message table must fail before any request is sent" - ); -} - -#[tokio::test] -async fn length_and_content_filter_with_tool_calls_fail_the_batch() { - // A truncated tool-call batch may hold partial JSON arguments; partial - // arguments must not execute, so the whole call fails as the answer. - let (echo, echo_calls) = fixture_tool("echo"); - let tools = ToolCatalog::new(&[echo]).expect("the fixture catalog is valid"); - let run = run_over_fixture( - r#" -models.use('test-model') -local ok1, err1 = pcall(function() - return models.chat({ { role = "user", content = "a" } }, { tools = { "echo" } }) -end) -local ok2, err2 = pcall(function() - return models.chat({ { role = "user", content = "b" } }, { tools = { "echo" } }) -end) -store.write('oks.txt', tostring(ok1) .. ' ' .. tostring(ok2)) -store.write('err1.txt', err1) -store.write('err2.txt', err2) -"#, - vec![tool_call_body("length"), tool_call_body("content_filter")], - tools, - config(), - ) - .await; - run.result - .as_ref() - .expect("the program catches both failures"); - assert_eq!(run.read("oks.txt"), "false false"); - assert!( - run.read("err1.txt").contains("truncated") && run.read("err1.txt").contains("length"), - "the length truncation must reach the program: {}", - run.read("err1.txt") - ); - assert!( - run.read("err2.txt").contains("truncated") - && run.read("err2.txt").contains("content_filter"), - "the content_filter truncation must reach the program: {}", - run.read("err2.txt") - ); - assert_eq!(echo_calls.load(Ordering::SeqCst), 0); -} - -#[tokio::test] -async fn the_observer_receives_each_content_event_exactly_once() { - let (echo, _) = fixture_tool("echo"); - let tools = ToolCatalog::new(&[echo]).expect("the fixture catalog is valid"); - let recorder = Arc::new(ContentRecorder::default()); - let thinking_body = json!({ - "model": "fixture-model", - "choices": [{ - "message": { - "role": "assistant", - "content": "considered reply", - "reasoning_content": "let me think", - }, - "finish_reason": "stop", - }], - "usage": { "prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6 }, - }); - let run = run_over_fixture( - r#" -models.use('test-model') -models.chat({ { role = "user", content = "think first" } }) -models.chat({ { role = "user", content = "now call" } }, { tools = { "echo" } }) -"#, - vec![thinking_body, tool_call_body("tool_calls")], - tools, - config_with(Arc::clone(&recorder) as Arc), - ) - .await; - run.result.as_ref().expect("both rounds complete"); - - let replies = recorder.replies.lock().expect("the reply log is intact"); - assert_eq!( - replies.len(), - 1, - "exactly one reply event for one text round" - ); - assert_eq!(replies[0].section, AGENT_NAME); - assert_eq!(replies[0].turn, 1); - assert_eq!(replies[0].text, "considered reply"); - assert_eq!(replies[0].finish_reason.as_deref(), Some("stop")); - assert_eq!(replies[0].model, "fixture-model"); - assert!( - replies[0].has_metrics, - "the reply event carries its metrics" - ); - - let thinking = recorder - .thinking - .lock() - .expect("the thinking log is intact"); - assert_eq!( - *thinking, - vec![("fixture-model".to_owned(), "let me think".to_owned())], - "thinking is captured exactly once with its model" - ); - - let batches = recorder.batches.lock().expect("the batch log is intact"); - assert_eq!( - batches.len(), - 1, - "exactly one tool-calls event for one tool round" - ); - assert_eq!(batches[0].0, "fixture-model"); - assert_eq!(batches[0].1.len(), 1); - assert_eq!(batches[0].1[0].id, "call_1"); - assert_eq!(batches[0].1[0].name, "echo"); - assert_eq!(batches[0].1[0].arguments, json!({ "value": "hi" })); -} - -#[tokio::test] -async fn deltas_reach_the_on_delta_callback() { - let deltas: Arc>> = Arc::new(Mutex::new(Vec::new())); - let sink = Arc::clone(&deltas); - let mut config = config(); - config.on_delta = Some(Arc::new(move |delta| { - sink.lock().expect("the delta log is intact").push(delta); - })); - let body = json!({ - "model": "fixture-model", - "choices": [{ - "message": { - "role": "assistant", - "content": "Hello agent", - "reasoning_content": "quick thought", - }, - "finish_reason": "stop", - }], - }); - let run = run_over_fixture( - r#" -models.use('test-model') -local result = models.chat({ { role = "user", content = "hi" } }) -store.write('reply.txt', result.reply) -"#, - vec![body], - no_tools(), - config, - ) - .await; - run.result.as_ref().expect("the streamed round completes"); - assert_eq!(run.read("reply.txt"), "Hello agent"); - let deltas = deltas.lock().expect("the delta log is intact"); - let text: String = deltas - .iter() - .filter_map(|delta| match delta { - StreamDelta::Text(fragment) => Some(fragment.as_str()), - _ => None, - }) - .collect(); - let reasoning: String = deltas - .iter() - .filter_map(|delta| match delta { - StreamDelta::Reasoning(fragment) => Some(fragment.as_str()), - _ => None, - }) - .collect(); - let text_fragments = deltas - .iter() - .filter(|delta| matches!(delta, StreamDelta::Text(_))) - .count(); - assert_eq!(text, "Hello agent"); - assert_eq!(reasoning, "quick thought"); - assert!( - text_fragments >= 2, - "the fixture splits content, so the callback must see live fragments, got {text_fragments}" - ); -} - -#[tokio::test] -async fn system_and_content_parts_messages_reach_the_wire_verbatim() { - let run = run_over_fixture( - r#" -models.use('test-model') -models.chat({ - { role = "system", content = "be terse" }, - { role = "user", content = { - { type = "text", text = "look" }, - { type = "image_url", image_url = { url = "data:image/png;base64,AA" } }, - } }, -}) -"#, - vec![text_body("fixture-model", "ok", "stop")], - no_tools(), - config(), - ) - .await; - run.result.as_ref().expect("the multimodal round completes"); - assert_eq!( - run.gateway.requests()[0]["messages"], - json!([ - { "role": "system", "content": "be terse" }, - { "role": "user", "content": [ - { "type": "text", "text": "look" }, - { "type": "image_url", "image_url": { "url": "data:image/png;base64,AA" } }, - ] }, - ]), - "system role and content parts must reach the wire exactly as validated" - ); -} - -#[tokio::test] -async fn chat_projects_multiple_leading_systems_without_mutating_the_programs_list() { - let run = run_over_fixture( - r#" -models.use('test-model') -local messages = { - { role = "system", content = "be terse" }, - { role = "system", content = "answer in English" }, - { role = "user", content = "hi" }, -} -models.chat(messages) -store.write('len.txt', tostring(#messages)) -store.write('first.txt', messages[1].content) -"#, - vec![text_body("fixture-model", "ok", "stop")], - no_tools(), - config(), - ) - .await; - run.result.as_ref().expect("the chat round completes"); - assert_eq!( - run.gateway.requests()[0]["messages"], - json!([ - { "role": "system", "content": "be terse\n\nanswer in English" }, - { "role": "user", "content": "hi" }, - ]), - "the provider's single-system shape is composed at dispatch" - ); - assert_eq!( - run.read("len.txt"), - "3", - "the source array is never mutated" - ); - assert_eq!(run.read("first.txt"), "be terse"); -} - -#[tokio::test] -async fn a_projection_failure_is_the_calls_error_and_no_request_leaves() { - let run = run_over_fixture( - r#" -models.use('test-model') -local ok, err = pcall(models.chat, { - { role = "user", content = "hi" }, - { role = "tool", content = "loose", tool_call_id = "call_9" }, -}) -store.write('ok.txt', tostring(ok)) -store.write('err.txt', tostring(err)) -"#, - vec![text_body("fixture-model", "unreachable", "stop")], - no_tools(), - config(), - ) - .await; - run.result - .as_ref() - .expect("the run completes: the program pcall'd the failure"); - assert_eq!(run.read("ok.txt"), "false"); - assert!( - run.read("err.txt").contains("is an orphan tool record"), - "the projection error rides back as the call's answer: {}", - run.read("err.txt") - ); - assert!( - run.gateway.requests().is_empty(), - "projection runs immediately before dispatch: an invalid list never reaches the gateway" - ); -} - -#[tokio::test] -async fn a_models_infer_round_completes_through_the_fixture_gateway() { - // The shared-kernel path: one completed models.infer round, its reply - // accumulated from the split SSE fragments. - let run = run_over_fixture( - r" -models.use('test-model') -store.write('infer.txt', models.infer('hello')) -", - vec![text_body("fixture-model", "an inferred reply", "stop")], - no_tools(), - config(), - ) - .await; - run.result.as_ref().expect("the infer round completes"); - assert_eq!( - run.read("infer.txt"), - "an inferred reply", - "the accumulated stream must resume into the program byte-exact" - ); - assert_eq!(run.gateway.call_count(), 1); - assert!( - run.gateway.requests()[0].get("tools").is_none(), - "models.infer advertises no tools" - ); -} - -#[tokio::test] -async fn an_agent_tool_call_is_counted_and_wraps_untrusted_output() { - let (plain, plain_calls) = fixture_tool("plain"); - let (tainted, tainted_calls) = fixture_tool_with_trust("tainted", false); - let tools = ToolCatalog::new(&[plain, tainted]).expect("the fixture catalog is valid"); - let run = run_over_fixture( - r" -store.write('count0.txt', tostring(tools.calls.plain)) -store.write('plain.txt', tools.call('plain', { value = 'hi' })) -store.write('wrapped.txt', tools.call('tainted', { value = 'hi' })) -store.write('counts.txt', tools.calls.plain .. ' ' .. tools.calls.tainted) -local ok, err = pcall(function() return tools.call('ghost', {}) end) -store.write('ghost_ok.txt', tostring(ok)) -store.write('ghost_err.txt', err) -", - vec![text_body("fixture-model", "never fetched", "stop")], - tools, - config(), - ) - .await; - run.result.as_ref().expect("the dispatch program completes"); - assert_eq!( - run.read("count0.txt"), - "0", - "tools.calls starts at zero for every catalog alias" - ); - assert_eq!( - run.read("plain.txt"), - "fixture output", - "a trusted tool's output resumes verbatim" - ); - let wrapped = run.read("wrapped.txt"); - assert!( - wrapped.contains(" ToolId { - self.id.clone() - } - - fn wire_name(&self) -> &'static str { - "structured" - } - - fn description(&self) -> &'static str { - "A fixture tool with structured output." - } - - fn parameters_schema(&self) -> Value { - json!({ "type": "object" }) - } - - fn structured_output(&self) -> bool { - true - } - - async fn call(&self, _args: Value) -> Result { - Ok(ToolOutput::trusted( - json!({ "text": "typed", "images": [] }).to_string(), - )) - } -} - -#[tokio::test] -async fn a_structured_tool_resumes_as_a_table() { - let structured: Arc = Arc::new(StructuredTool { - id: ToolId::new("fixture", "structured").expect("the fixture tool id is valid"), - }); - let tools = ToolCatalog::new(&[structured]).expect("the fixture catalog is valid"); - let run = run_over_fixture( - r" -local result = tools.call('structured', {}) -store.write('type.txt', type(result)) -store.write('text.txt', result.text) -store.write('images.txt', tostring(#result.images)) -", - vec![text_body("fixture-model", "never fetched", "stop")], - tools, - config(), - ) - .await; - run.result - .as_ref() - .expect("the structured program completes"); - assert_eq!( - run.read("type.txt"), - "table", - "a structured_output tool binds structured and resumes as a Lua table" - ); - assert_eq!(run.read("text.txt"), "typed"); - assert_eq!(run.read("images.txt"), "0"); -} - -#[tokio::test] -async fn firing_cancel_interrupts_a_suspended_tool_call() { - let started = Arc::new(tokio::sync::Notify::new()); - let blocking: Arc = Arc::new(BlockingTool { - id: ToolId::new("fixture", "blocking").expect("the fixture tool id is valid"), - started: Arc::clone(&started), - }); - let tools = ToolCatalog::new(&[blocking]).expect("the fixture catalog is valid"); - let cancel = CancelHandle::new(); - let fire = cancel.clone(); - let mut config = config(); - config.cancel = cancel; - let run = tokio::spawn(async move { - let vfs = promptforge_vfs::empty(); - run_agent( - "tools.call('blocking', {})", - &tools, - &fixture_models(), - &vfs, - config, - ) - .await - }); - // The tool signals once its call is in flight, so the cancel provably - // races a suspended dispatch, not a program that never reached it. - started.notified().await; - fire.cancel(); - let result = run.await.expect("the run task joins"); - assert!( - matches!(result, Err(AgentError::Interrupted)), - "a cancelled suspended tool call must interrupt the run, got {result:?}" - ); -} - -#[tokio::test] -async fn chat_turn_events_become_visible_after_the_next_resume_not_before() { - let log = Arc::new(FixtureEventLog::default()); - let mut config = config_with(Arc::clone(&log) as Arc); - config.event_log = Some(Arc::clone(&log) as Arc); - let run = run_over_fixture( - r#" -models.use('test-model') -local events = runtime.events() -store.write('n0.txt', tostring(#events)) -log('poke') -store.write('n1.txt', tostring(#events)) -models.chat({ { role = "user", content = "hi" } }) -store.write('n2.txt', tostring(#events)) -store.write('poke.txt', events[1].kind .. ' ' .. events[1].content) -store.write('reply.txt', events[2].kind .. ' ' .. events[2].content .. ' ' .. events[2].model) -"#, - vec![text_body("fixture-model", "Hello agent", "stop")], - no_tools(), - config, - ) - .await; - run.result.as_ref().expect("the events program completes"); - assert_eq!(run.read("n0.txt"), "0"); - assert_eq!( - run.read("n1.txt"), - "0", - "an append landing mid-chunk (the log() poke) must stay invisible until a host-call resume" - ); - assert_eq!( - run.read("n2.txt"), - "2", - "the poke and the chat reply must both become visible at the chat resume" - ); - assert_eq!( - run.read("poke.txt"), - "user_message poke", - "entries convert with their pinned kind labels and byte-exact content" - ); - assert_eq!( - run.read("reply.txt"), - "agent_message Hello agent fixture-model", - "the chat turn's reply event reads back with its model attribution" - ); -} - -#[tokio::test] -async fn an_absent_event_log_yields_an_empty_table() { - let run = run_over_fixture( - r" -local events = runtime.events() -store.write('type.txt', type(events)) -store.write('len.txt', tostring(#events)) -store.write('first.txt', tostring(events[1] == nil)) -", - vec![text_body("fixture-model", "never fetched", "stop")], - no_tools(), - config(), - ) - .await; - run.result - .as_ref() - .expect("the empty-events program completes"); - assert_eq!(run.read("type.txt"), "table"); - assert_eq!(run.read("len.txt"), "0"); - assert_eq!(run.read("first.txt"), "true"); -} diff --git a/crates/promptforge-core/AGENTS.md b/crates/promptforge-api/AGENTS.md similarity index 51% rename from crates/promptforge-core/AGENTS.md rename to crates/promptforge-api/AGENTS.md index 931513cc1..9b0aac6dc 100644 --- a/crates/promptforge-core/AGENTS.md +++ b/crates/promptforge-api/AGENTS.md @@ -1,9 +1,10 @@ -# promptforge-core +# promptforge-api This crate owns PromptForge document execution and run orchestration. -- Historical `promptforge_core` compatibility paths are verbatim re-exports from the owning crates. Do not create new compatibility vocabulary here. +- Historical `promptforge_api` compatibility paths are verbatim re-exports from the owning crates. Do not create new compatibility vocabulary here. - Concrete providers stay in their provider crates. Core may re-export them under a historical path but never reacquires provider implementation. - Store write scope remains private to Core's execution machinery. - The executor imports parser, Lua, model-client, store, tool, and host-support vocabulary from their owning crates. Those crates never depend on this executor. +- One door: this crate is the only promptforge-* dependency an outside crate (workshop-*, gateway-*, shared-*, build-*) may name. The internal substrate crates (promptforge-parser, promptforge-lua, promptforge-model-client, promptforge-store, promptforge-tool-picker, promptforge-vfs, promptforge-webfetch, promptforge-web-search) are internal; `cargo test -p build-xtask` enforces the boundary. - The input broker backs only the script-side `user_input()` function. No `user_input` tool is ever advertised to a model unless a prompt explicitly adds it. diff --git a/crates/promptforge-core/Cargo.toml b/crates/promptforge-api/Cargo.toml similarity index 78% rename from crates/promptforge-core/Cargo.toml rename to crates/promptforge-api/Cargo.toml index 48d7512ac..19b2a7100 100644 --- a/crates/promptforge-core/Cargo.toml +++ b/crates/promptforge-api/Cargo.toml @@ -1,26 +1,25 @@ [package] -name = "promptforge-core" +name = "promptforge-api" version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true publish = false readme = "README.md" -keywords = ["prompt", "llm", "ai", "runtime", "openai"] -categories = ["text-processing", "api-bindings"] +keywords = ["promptforge", "prompt", "llm", "agent", "ai"] +categories = ["development-tools", "text-processing", "api-bindings"] -description = "PromptForge runtime core: prompt parser, HTTP client, section execution" +description = "PromptForge API: prompt parser, HTTP client, section execution" documentation = "https://cppalliance.github.io/promptforge/" [dependencies] async-trait.workspace = true -promptforge-core-support.workspace = true +shared-promptforge-api.workspace = true promptforge-lua.workspace = true promptforge-model-client.workspace = true promptforge-parser.workspace = true promptforge-store.workspace = true promptforge-tool-picker.workspace = true -promptforge-tools.workspace = true promptforge-vfs.workspace = true promptforge-web-search.workspace = true rand.workspace = true diff --git a/crates/promptforge-core/README.md b/crates/promptforge-api/README.md similarity index 57% rename from crates/promptforge-core/README.md rename to crates/promptforge-api/README.md index a0289abea..4c4bfc364 100644 --- a/crates/promptforge-core/README.md +++ b/crates/promptforge-api/README.md @@ -1,8 +1,8 @@ -# promptforge-core +# promptforge-api -[![Crates.io](https://img.shields.io/crates/v/promptforge-core.svg)](https://crates.io/crates/promptforge-core) -[![docs.rs](https://img.shields.io/docsrs/promptforge-core)](https://docs.rs/promptforge-core) -[![License](https://img.shields.io/crates/l/promptforge-core)](LICENSE) +[![Crates.io](https://img.shields.io/crates/v/promptforge-api.svg)](https://crates.io/crates/promptforge-api) +[![docs.rs](https://img.shields.io/docsrs/promptforge-api)](https://docs.rs/promptforge-api) +[![License](https://img.shields.io/crates/l/promptforge-api)](LICENSE) A Rust library that turns Markdown files into executable AI prompt pipelines. You write a prompt as a document - YAML frontmatter for metadata, embedded Lua for logic, prose blocks for model instructions - and the library parses it into a validated representation, then executes it against any OpenAI-compatible endpoint. Structured multi-section prompts with tool dispatch, model orchestration, concurrent fanout, and a virtual filesystem, all driven from a single `run` call that returns a string. @@ -10,30 +10,27 @@ A Rust library that turns Markdown files into executable AI prompt pipelines. Yo ```toml [dependencies] -promptforge-core = "0.1" -promptforge-tool-picker = "0.1" -promptforge-vfs = "0.1" +promptforge-api = "0.1" +shared-promptforge-api = "0.1" ``` ```rust -use promptforge_core::model::ModelCatalog; -use promptforge_core::observe::NullObserver; -use promptforge_core::tools::ToolCatalog; -use promptforge_core::{Prompt, ResolutionContext, RunConfig, run}; -use promptforge_tool_picker::{Catalog, Config, ToolPicker}; +use promptforge_api::{Prompt, ResolutionContext, RunConfig, run}; +use shared_promptforge_api::models::ModelCatalog; +use shared_promptforge_api::observe::NullObserver; +use shared_promptforge_api::tools::ToolCatalog; async fn execute(source: &str) -> Result> { let prompt = Prompt::parse(source, "readme", &NullObserver::default())?; - let picker = ToolPicker::build(Catalog::new(Vec::new()), Config::default())?; + // Capability-free agents pass no picker; the store handle defaults to a + // stock in-memory mount. let models = ModelCatalog::empty(); let tools = ToolCatalog::new(&[])?; - let vfs = promptforge_vfs::empty(); let result = run( &prompt, "", - ResolutionContext::new(&picker, &models, &tools), - &vfs, + ResolutionContext::new(None, &models, &tools), RunConfig::new("readme"), ) .await?; diff --git a/crates/promptforge-core/benches/models_loop.rs b/crates/promptforge-api/benches/models_loop.rs similarity index 93% rename from crates/promptforge-core/benches/models_loop.rs rename to crates/promptforge-api/benches/models_loop.rs index 90c11ab4a..eb46212cc 100644 --- a/crates/promptforge-core/benches/models_loop.rs +++ b/crates/promptforge-api/benches/models_loop.rs @@ -2,7 +2,7 @@ //! over one scripted terminal turn, and the `compactors.fail` invocation on //! a precheck overflow. //! -//! Run with `cargo bench -p promptforge-core`. +//! Run with `cargo bench -p promptforge-api`. // The criterion_group! macro expansion generates an undocumented public // entry point; bench targets have no docs contract. @@ -25,13 +25,12 @@ use axum::extract::State; use axum::response::IntoResponse; use axum::routing::post; use criterion::{Criterion, criterion_group, criterion_main}; -use promptforge_core::client::{GatewayClient, GatewayEndpoint, SecretString}; -use promptforge_core::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; -use promptforge_core::observe::NullObserver; - -use promptforge_core::tools::ToolCatalog; -use promptforge_core::{Prompt, ResolutionContext, RunConfig, run}; +use promptforge_api::client::{GatewayClient, GatewayEndpoint, SecretString}; +use promptforge_api::{Prompt, ResolutionContext, RunConfig, run}; use promptforge_tool_picker::{Catalog, Config, ToolPicker}; +use shared_promptforge_api::models::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; +use shared_promptforge_api::observe::NullObserver; +use shared_promptforge_api::tools::ToolCatalog; const EXECUTION: &str = "bench"; @@ -149,7 +148,7 @@ fn resolution<'a>( models: &'a ModelCatalog, tools: &'a ToolCatalog, ) -> ResolutionContext<'a> { - ResolutionContext::new(picker, models, tools) + ResolutionContext::new(Some(picker), models, tools) } /// One `models.loop` turn end to end: parse is excluded, so the measurement @@ -174,7 +173,6 @@ fn models_loop(c: &mut Criterion) { &prompt, "", resolution(&picker, &models, &tools), - &promptforge_vfs::empty(), RunConfig::new(EXECUTION) .observer(Arc::new(NullObserver::default())) .client(gateway.client()), @@ -210,7 +208,6 @@ fn compactors_fail(c: &mut Criterion) { &prompt, "", resolution(&picker, &models, &tools), - &promptforge_vfs::empty(), RunConfig::new(EXECUTION) .observer(Arc::new(NullObserver::default())) .client(gateway.client()), @@ -218,7 +215,7 @@ fn compactors_fail(c: &mut Criterion) { .expect_err("a one-token window must exhaust at the precheck"); assert_eq!( error.kind(), - promptforge_core::RunErrorKind::ContextExhausted, + promptforge_api::RunErrorKind::ContextExhausted, "the default compactor is compactors.fail: {error:?}" ); }); diff --git a/crates/promptforge-api/src/cancel.rs b/crates/promptforge-api/src/cancel.rs new file mode 100644 index 000000000..9754bcc9d --- /dev/null +++ b/crates/promptforge-api/src/cancel.rs @@ -0,0 +1,9 @@ +//! Cooperative cancellation for long-running execute paths. +//! +//! The implementation lives in the `shared-promptforge-api` crate and is +//! re-exported here unchanged, so existing `promptforge_api::cancel::*` paths +//! keep working. + +pub(crate) use shared_promptforge_api::cancel::{ + CancelHandle, current, is_cancelled, maybe_scope, wait_cancelled, +}; diff --git a/crates/promptforge-core/src/client.rs b/crates/promptforge-api/src/client.rs similarity index 58% rename from crates/promptforge-core/src/client.rs rename to crates/promptforge-api/src/client.rs index bd470094b..be26baa73 100644 --- a/crates/promptforge-core/src/client.rs +++ b/crates/promptforge-api/src/client.rs @@ -3,7 +3,7 @@ //! The client speaks `/chat/completions` and always streams SSE internally: //! [`GatewayClient::complete`] accumulates the deltas into one text reply or //! the tool calls the model asked for, invoking the caller's delta callback -//! with each live [`StreamDelta`]. [`GatewayClient::complete`] sends a +//! with each live delta. [`GatewayClient::complete`] sends a //! `tools` array when the caller supplies one, so the executor's tool-call //! loop runs over this client. The client holds only the gateway's URL and //! the shared key; the vendor credential lives in the gateway, so the @@ -11,10 +11,13 @@ //! or another gateway to retarget it. //! //! The implementation lives in the `promptforge-model-client` crate and is -//! re-exported here unchanged, so existing `promptforge_core::client::*` paths -//! keep working. +//! re-exported here: hosts pass a [`GatewayClient`] to +//! [`RunConfig::client`](crate::RunConfig) and classify its failures through +//! [`CompletionError`]. -pub use promptforge_model_client::client::{ - Completion, CompletionResult, GatewayClient, GatewayEndpoint, Message, SecretError, - SecretString, StreamDelta, ToolArguments, ToolCall, ToolSchema, +pub use promptforge_model_client::client::{GatewayClient, GatewayEndpoint, SecretString}; +pub use promptforge_model_client::model::{CompletionError, CompletionErrorKind}; + +pub(crate) use promptforge_model_client::client::{ + Completion, CompletionResult, Message, StreamDelta, ToolCall, ToolSchema, }; diff --git a/crates/promptforge-core/src/debug.rs b/crates/promptforge-api/src/debug.rs similarity index 97% rename from crates/promptforge-core/src/debug.rs rename to crates/promptforge-api/src/debug.rs index ab0634ec6..373fb311a 100644 --- a/crates/promptforge-core/src/debug.rs +++ b/crates/promptforge-api/src/debug.rs @@ -2,7 +2,7 @@ //! //! [`DebugCapture`] receives owned request and response payloads for a host //! that wants them on disk or in a debugger. It is a separate seam from -//! [`crate::observe::Observer`]: observations stay payload-free, and production +//! [`shared_promptforge_api::observe::Observer`]: observations stay payload-free, and production //! hosts leave [`crate::execute::RunConfig::debug`] unset so they pay //! nothing for this path. @@ -39,7 +39,7 @@ use serde_json::Value; /// /// ``` /// use std::sync::Mutex; -/// use promptforge_core::debug::{DebugCapture, DebugEvent}; +/// use promptforge_api::debug::{DebugCapture, DebugEvent}; /// /// #[derive(Default)] /// struct QueueCapture { diff --git a/crates/promptforge-core/src/error.rs b/crates/promptforge-api/src/error.rs similarity index 98% rename from crates/promptforge-core/src/error.rs rename to crates/promptforge-api/src/error.rs index f9f67e588..c9c9b8628 100644 --- a/crates/promptforge-core/src/error.rs +++ b/crates/promptforge-api/src/error.rs @@ -3,7 +3,7 @@ //! [`Error`] is a `pub(crate)` substrate: it is never part of the public API. //! Every public boundary returns its own typed error ([`crate::RunError`], //! [`crate::ParseError`], [`crate::CompletionError`], -//! [`crate::tools::ToolError`], [`crate::store::StoreError`]); those wrappers +//! [`shared_promptforge_api::tools::ToolError`], [`promptforge_store::StoreError`]); those wrappers //! classify this substrate and preserve its source. See the module wrappers for //! the `From` bridges that let internal `?` keep flowing through the substrate. @@ -475,9 +475,9 @@ pub(crate) enum Error { #[error("unsupported promptforge version: {0} (this build supports major 0)")] UnsupportedVersion(u32), - /// A dispatched [`crate::tools::Tool`] returned a model-safe failure. + /// A dispatched [`shared_promptforge_api::tools::Tool`] returned a model-safe failure. /// - /// The tool's own [`crate::tools::ToolError`] is preserved as the + /// The tool's own [`shared_promptforge_api::tools::ToolError`] is preserved as the /// `#[source]` cause, so the failure chain (and any transport/parse error the /// tool wrapped) survives instead of being flattened to a string. #[error("tool call failure: {message}")] @@ -644,8 +644,8 @@ impl From for Error { } } -impl From for Error { - fn from(error: crate::model::CompletionError) -> Error { +impl From for Error { + fn from(error: crate::client::CompletionError) -> Error { Error::from(GatewayClientError::from(error)) } } @@ -882,8 +882,7 @@ mod tests { // client :419 / AUDIT-DISCARDED-SOURCE: an unusable credential and a bad // endpoint URL both retain their concrete cause through the public // CompletionError::source, classified as Config. - use crate::client::{GatewayEndpoint, SecretString}; - use crate::model::{CompletionError, CompletionErrorKind}; + use crate::client::{CompletionError, CompletionErrorKind, GatewayEndpoint, SecretString}; let secret_error = SecretString::new("").expect_err("blank key is rejected"); let completion = CompletionError::from(secret_error); diff --git a/crates/promptforge-core/src/execute.rs b/crates/promptforge-api/src/execute.rs similarity index 92% rename from crates/promptforge-core/src/execute.rs rename to crates/promptforge-api/src/execute.rs index a025b74b9..8375191da 100644 --- a/crates/promptforge-core/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -16,7 +16,8 @@ //! the same rules, and the parent walk resumes after the jumper when that //! level exhausts. //! -//! One run-scoped [`VfsRef`] is created once by the caller and threaded through +//! One run-scoped store handle travels with the run's [`RunConfig`] (the +//! stock handle by default), shared by //! every section, so //! bulk state persists across the context-clearing transitions even though a //! section's Lua state never does. @@ -25,7 +26,7 @@ //! `(execution, section, event)` record when the run starts and ends, at each //! section boundary, model turn, tool call, and harness-mediated store //! operation. Reporting is a side channel and never -//! a decision, so passing [`crate::observe::NullObserver`] changes nothing but +//! a decision, so passing [`NullObserver`](shared_promptforge_api::observe::NullObserver) changes nothing but //! the silence. //! //! Rust installs tool bindings captured from live H1 into each section VM. @@ -138,12 +139,11 @@ use crate::store::VfsRef; /// structural request the scheduler drives on the run's one thread, so the /// current-thread runtime below runs the whole prompt, host calls included: /// ``` -/// use promptforge_core::execute::{run, RunConfig, ResolutionContext}; -/// use promptforge_core::model::ModelCatalog; -/// use promptforge_core::observe::NullObserver; -/// use promptforge_core::parser::Prompt; -/// use promptforge_core::tools::ToolCatalog; -/// use promptforge_tool_picker::{Catalog, Config, ToolPicker}; +/// use promptforge_api::execute::{run, RunConfig, ResolutionContext}; +/// use promptforge_api::parser::Prompt; +/// use shared_promptforge_api::models::ModelCatalog; +/// use shared_promptforge_api::observe::NullObserver; +/// use shared_promptforge_api::tools::ToolCatalog; /// /// let source = concat!( /// "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n", @@ -154,7 +154,6 @@ use crate::store::VfsRef; /// "```lua\nreturn 'hello'\n```\n", /// ); /// let prompt = Prompt::parse(source, "doc-example", &NullObserver::default())?; -/// let picker = ToolPicker::build(Catalog::new(Vec::new()), Config::default())?; /// let models = ModelCatalog::empty(); /// let tools = ToolCatalog::new(&[])?; /// @@ -162,8 +161,7 @@ use crate::store::VfsRef; /// let output = runtime.block_on(run( /// &prompt, /// "", -/// ResolutionContext::new(&picker, &models, &tools), -/// &promptforge_vfs::empty(), +/// ResolutionContext::new(None, &models, &tools), /// RunConfig::new("doc-example"), /// ))?; /// assert_eq!(output, "hello"); @@ -183,7 +181,6 @@ pub async fn run( prompt: &Prompt, args: &str, resolution: ResolutionContext<'_>, - vfs: &VfsRef, config: RunConfig, ) -> std::result::Result { match prompt.frontmatter().promptforge() { @@ -210,19 +207,18 @@ pub async fn run( // it gets a fresh memory store overlaid as a defensive fallback, so a // run never fails for want of the mount. A mounted-but-failing backend // is never shadowed by the throwaway overlay: its error fails the run. - let fallback; - let vfs = match store_mount_present(vfs) { - Ok(true) => vfs, + let mut config = config; + match store_mount_present(&config.vfs) { + Ok(true) => {} Ok(false) => { - fallback = vfs.overlay( + config.vfs = config.vfs.overlay( promptforge_vfs::STORE_MOUNT, shared_vfs::MemoryBackend::new(), ); - &fallback } Err(error) => return Err(RunError::from(Error::Store(error))), - }; - let ctx = RunContext::new(prompt, args, vfs, shared, &config); + } + let ctx = RunContext::new(prompt, args, &config.vfs, shared, &config); let RunConfig { execution, diff --git a/crates/promptforge-core/src/execute/config.rs b/crates/promptforge-api/src/execute/config.rs similarity index 90% rename from crates/promptforge-core/src/execute/config.rs rename to crates/promptforge-api/src/execute/config.rs index 4d989e23b..ab90deb03 100644 --- a/crates/promptforge-core/src/execute/config.rs +++ b/crates/promptforge-api/src/execute/config.rs @@ -10,6 +10,7 @@ use crate::client::{GatewayClient, StreamDelta}; use crate::debug::DebugCapture; use crate::input::InputBroker; use crate::observe::{NullObserver, Observer}; +use crate::store::VfsRef; /// Generates one `nz_*` constructor per `NonZero*` type: a `const fn` /// building the wrapper from a compile-time-known non-zero value. @@ -42,7 +43,7 @@ nz!(nz_usize, NonZeroUsize, usize); /// ``` /// use std::num::NonZeroU32; /// -/// use promptforge_core::execute::RunLimits; +/// use promptforge_api::execute::RunLimits; /// /// let eight = NonZeroU32::new(8).ok_or("8 is non-zero")?; /// let limits = RunLimits::new().max_tool_iterations(eight); @@ -66,7 +67,7 @@ impl RunLimits { /// /// # Examples /// ``` - /// use promptforge_core::execute::RunLimits; + /// use promptforge_api::execute::RunLimits; /// /// assert_eq!(RunLimits::new().tool_iterations().get(), 24); /// ``` @@ -167,16 +168,17 @@ impl Default for RunLimits { } } -/// Everything a run needs beyond the prompt, its input, its tools, and its -/// store: the execution id, where progress is reported, the raw-capture seam, -/// the gateway client, an explicit cancellation handle, and resource limits. +/// Everything a run needs beyond the prompt, its input, and its tools: the +/// execution id, where progress is reported, the raw-capture seam, +/// the gateway client, an explicit cancellation handle, resource limits, and +/// the store handle. /// /// `RunConfig` is owned (no borrows), so its observer and debug sinks reach the /// nested `models.infer` path that a borrowed option could not. /// /// # Examples /// ``` -/// use promptforge_core::execute::{RunConfig, RunLimits}; +/// use promptforge_api::execute::{RunConfig, RunLimits}; /// /// let config = RunConfig::new("example-run").limits(RunLimits::new()); /// assert_eq!(config.execution(), "example-run"); @@ -192,12 +194,14 @@ pub struct RunConfig { pub(crate) input: Option>, pub(crate) ui: Option serde_json::Value + Send + Sync>>, pub(crate) on_delta: Option>, + pub(crate) vfs: VfsRef, } impl RunConfig { /// Builds a config for `execution` with default observer, no client, no /// capture, no cancellation, no input broker, no `ui` provider, no delta - /// callback, and default [`RunLimits`]. + /// callback, default [`RunLimits`], and the stock store handle + /// (`promptforge_vfs::empty()`). #[must_use] pub fn new(execution: impl Into) -> RunConfig { RunConfig { @@ -210,6 +214,7 @@ impl RunConfig { input: None, ui: None, on_delta: None, + vfs: promptforge_vfs::empty(), } } @@ -282,6 +287,17 @@ impl RunConfig { self } + /// Sets the run's VFS handle, which carries the store mount every + /// section's `store` table operates on. Hosts that seed before the run + /// or extract after it build their own handle and set it here; the + /// default is the stock handle (`promptforge_vfs::empty()`), a fresh + /// memory backend at the store mount. + #[must_use] + pub fn vfs(mut self, vfs: VfsRef) -> RunConfig { + self.vfs = vfs; + self + } + /// Returns the execution identifier shared by every report. #[must_use] pub fn execution(&self) -> &str { @@ -301,6 +317,7 @@ impl fmt::Debug for RunConfig { .field("input", &self.input.is_some()) .field("ui", &self.ui.is_some()) .field("on_delta", &self.on_delta.is_some()) + .field("vfs", &self.vfs) .finish() } } diff --git a/crates/promptforge-core/src/execute/context.rs b/crates/promptforge-api/src/execute/context.rs similarity index 100% rename from crates/promptforge-core/src/execute/context.rs rename to crates/promptforge-api/src/execute/context.rs diff --git a/crates/promptforge-core/src/execute/engine.rs b/crates/promptforge-api/src/execute/engine.rs similarity index 100% rename from crates/promptforge-core/src/execute/engine.rs rename to crates/promptforge-api/src/execute/engine.rs diff --git a/crates/promptforge-core/src/execute/error.rs b/crates/promptforge-api/src/execute/error.rs similarity index 100% rename from crates/promptforge-core/src/execute/error.rs rename to crates/promptforge-api/src/execute/error.rs diff --git a/crates/promptforge-core/src/execute/gateway.rs b/crates/promptforge-api/src/execute/gateway.rs similarity index 88% rename from crates/promptforge-core/src/execute/gateway.rs rename to crates/promptforge-api/src/execute/gateway.rs index 1cbb1cc3a..e13b26ca1 100644 --- a/crates/promptforge-core/src/execute/gateway.rs +++ b/crates/promptforge-api/src/execute/gateway.rs @@ -15,8 +15,10 @@ use super::config::RunLimits; #[derive(Clone, Copy)] #[non_exhaustive] pub struct ResolutionContext<'a> { - /// Semantic picker used by executed H1 capability calls. - pub(crate) picker: &'a ToolPicker, + /// Semantic picker used by executed H1 capability calls. `None` for + /// capability-free agents: a `tools.bind` or `models.bind` executed + /// without a picker fails as a binding error naming the missing picker. + pub(crate) picker: Option<&'a ToolPicker>, /// Live model catalog used by executed H1 model calls. pub(crate) models: &'a ModelCatalog, /// Caller-provided tool catalog used by executed H1 `tools.bind` calls. @@ -24,11 +26,11 @@ pub struct ResolutionContext<'a> { } impl<'a> ResolutionContext<'a> { - /// Builds a resolution context from a live picker, model catalog, and - /// tool catalog. + /// Builds a resolution context from an optional live picker, a model + /// catalog, and a tool catalog. #[must_use] pub fn new( - picker: &'a ToolPicker, + picker: Option<&'a ToolPicker>, models: &'a ModelCatalog, tools: &'a ToolCatalog, ) -> ResolutionContext<'a> { diff --git a/crates/promptforge-core/src/execute/protocol.rs b/crates/promptforge-api/src/execute/protocol.rs similarity index 100% rename from crates/promptforge-core/src/execute/protocol.rs rename to crates/promptforge-api/src/execute/protocol.rs diff --git a/crates/promptforge-core/src/execute/scheduler.rs b/crates/promptforge-api/src/execute/scheduler.rs similarity index 99% rename from crates/promptforge-core/src/execute/scheduler.rs rename to crates/promptforge-api/src/execute/scheduler.rs index 00510f227..4761d9160 100644 --- a/crates/promptforge-core/src/execute/scheduler.rs +++ b/crates/promptforge-api/src/execute/scheduler.rs @@ -483,11 +483,12 @@ pub(crate) struct Scheduler<'a> { answers: mpsc::UnboundedReceiver<(RequestId, Answer)>, /// Join handles of the in-flight leaf I/O tasks, keyed by request so /// a fatal fanout arm can abort a sibling arm's own in-flight round; - /// every handle is aborted on cancellation, and aborting a completed - /// task is a no-op. The handles are kept joinable (not bare abort - /// handles) so a terminal run outcome can drain them: a store op - /// runs on the blocking pool, where abort detaches rather than - /// interrupts, and only the op's completion drops its access clone. + /// every handle is aborted on cancellation or on the driver future's + /// drop, and aborting a completed task is a no-op. The handles are + /// kept joinable (not bare abort handles) so a terminal run outcome + /// can drain them: a store op runs on the blocking pool, where abort + /// detaches rather than interrupts, and only the op's completion + /// drops its access clone. io_tasks: HashMap>, /// The request ids whose in-flight tasks an abort discarded: a task /// that posted its answer before the abort landed delivers it late, @@ -517,6 +518,22 @@ pub(crate) struct Scheduler<'a> { h1_resolution: Option>, } +/// Aborts every in-flight leaf task when the driver future is dropped +/// mid-suspension - a host tearing the run down without polling it to a +/// terminal state. Dropping a bare `JoinHandle` detaches the task, which +/// would strand a broker wait or gateway round forever (a session close +/// would leak its pending input wait and never emit `input_cancelled`), +/// so the drop path applies the same abort the cancellation path does. +/// The claims-release join in [`Self::drain_io_tasks`] is unnecessary +/// here: a dropped run delivers no result. +impl Drop for Scheduler<'_> { + fn drop(&mut self) { + for handle in self.io_tasks.values() { + handle.abort(); + } + } +} + impl<'a> Scheduler<'a> { /// Builds the scheduler for one run over `ctx`'s prompt. `client` is the /// run's gateway client, if the caller supplied one; otherwise each diff --git a/crates/promptforge-core/src/execute/scope.rs b/crates/promptforge-api/src/execute/scope.rs similarity index 100% rename from crates/promptforge-core/src/execute/scope.rs rename to crates/promptforge-api/src/execute/scope.rs diff --git a/crates/promptforge-core/src/execute/section_context.rs b/crates/promptforge-api/src/execute/section_context.rs similarity index 100% rename from crates/promptforge-core/src/execute/section_context.rs rename to crates/promptforge-api/src/execute/section_context.rs diff --git a/crates/promptforge-core/src/execute/section_vm.rs b/crates/promptforge-api/src/execute/section_vm.rs similarity index 100% rename from crates/promptforge-core/src/execute/section_vm.rs rename to crates/promptforge-api/src/execute/section_vm.rs diff --git a/crates/promptforge-core/src/execute/support.rs b/crates/promptforge-api/src/execute/support.rs similarity index 100% rename from crates/promptforge-core/src/execute/support.rs rename to crates/promptforge-api/src/execute/support.rs diff --git a/crates/promptforge-core/src/execute/tests/debug_and_counts.rs b/crates/promptforge-api/src/execute/tests/debug_and_counts.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/debug_and_counts.rs rename to crates/promptforge-api/src/execute/tests/debug_and_counts.rs diff --git a/crates/promptforge-core/src/execute/tests/exec_flow.rs b/crates/promptforge-api/src/execute/tests/exec_flow.rs similarity index 95% rename from crates/promptforge-core/src/execute/tests/exec_flow.rs rename to crates/promptforge-api/src/execute/tests/exec_flow.rs index b69351119..b7a762e2c 100644 --- a/crates/promptforge-core/src/execute/tests/exec_flow.rs +++ b/crates/promptforge-api/src/execute/tests/exec_flow.rs @@ -2304,9 +2304,8 @@ async fn a_mount_less_handle_runs_on_the_defensive_store_overlay() { let out = crate::execute::run( &test.prompt, "", - ResolutionContext::new(&picker, &test.models, &ToolCatalog::default()), - &vfs, - RunConfig::new(EXECUTION), + ResolutionContext::new(Some(&picker), &test.models, &ToolCatalog::default()), + RunConfig::new(EXECUTION).vfs(vfs.clone()), ) .await .expect("a mount-less handle gets the defensive memory-store overlay"); @@ -2326,3 +2325,103 @@ async fn a_mount_less_handle_runs_on_the_defensive_store_overlay() { "the run's writes must land on the overlay, not the caller's backend" ); } + +#[tokio::test] +async fn picker_less_context_runs_a_capability_free_prompt() { + // The picker is optional: a prompt with no capability binds runs under + // `ResolutionContext::new(None, ...)`. + let md = flow_prompt!( + "# Test prompt\n\n\ + ## Only\n\n```lua\nreturn 'no capabilities'\n```\n" + ); + let test = fixture(md); + let out = crate::execute::run( + &test.prompt, + "", + ResolutionContext::new(None, &test.models, &ToolCatalog::default()), + RunConfig::new(EXECUTION), + ) + .await + .expect("a capability-free prompt runs without a picker"); + assert_eq!(out, "no capabilities"); +} + +#[tokio::test] +async fn default_run_config_store_handle_carries_the_stock_mount() { + // `RunConfig` absorbs the store handle with a `promptforge_vfs::empty()` + // default: a store-using run needs no host-supplied handle. + let md = flow_prompt!( + "# Test prompt\n\n\ + ## First\n\n```lua\nstore.write('default.txt', 'stock')\n```\n\n\ + ## Second\n\n```lua\nreturn store.read('default.txt')\n```\n" + ); + let test = fixture(md); + let out = crate::execute::run( + &test.prompt, + "", + ResolutionContext::new(None, &test.models, &ToolCatalog::default()), + RunConfig::new(EXECUTION), + ) + .await + .expect("the default store handle carries the stock mount"); + assert_eq!(out, "stock"); +} + +#[tokio::test] +async fn picker_less_context_fails_a_tool_bind_as_a_binding_error() { + // A `tools.bind` under a picker-less context fails classified as a + // binding failure, naming the missing picker. + let md = flow_prompt!( + "# Test prompt\n\n\ + ```lua\ntools.bind('search', 'search the web')\n```\n\n\ + ## Only\n\n```lua\nreturn 'unreachable'\n```\n" + ); + let test = fixture(md); + let error = crate::execute::run( + &test.prompt, + "", + ResolutionContext::new(None, &test.models, &ToolCatalog::default()), + RunConfig::new(EXECUTION), + ) + .await + .expect_err("a tools.bind without a picker must fail"); + assert_eq!( + error.kind(), + RunErrorKind::Binding, + "a picker-less tools.bind classifies as Binding: {error:?}" + ); + assert!( + error.to_string().contains("no tool picker"), + "the failure names the missing picker: {error}" + ); +} + +#[tokio::test] +async fn picker_less_context_fails_a_model_bind_as_a_binding_error() { + // A non-empty catalog still cannot bind without the picker: the failure + // is the missing picker, not the empty-catalog absent shortcut. + let md = flow_prompt!( + "# Test prompt\n\n\ + ```lua\nmodels.bind('writer', 'A general model for tests')\n```\n\n\ + ## Only\n\n```lua\nreturn 'unreachable'\n```\n" + ); + let mut test = fixture(md); + test.models = test_model_catalog(); + let error = crate::execute::run( + &test.prompt, + "", + ResolutionContext::new(None, &test.models, &ToolCatalog::default()), + RunConfig::new(EXECUTION), + ) + .await + .expect_err("a models.bind without a picker must fail"); + assert_eq!( + error.kind(), + RunErrorKind::Binding, + "a picker-less models.bind classifies as Binding: {error:?}" + ); + assert!( + error.to_string().contains("no tool picker"), + "the failure names the missing picker: {error}" + ); +} diff --git a/crates/promptforge-core/src/execute/tests/exit_rules.rs b/crates/promptforge-api/src/execute/tests/exit_rules.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/exit_rules.rs rename to crates/promptforge-api/src/execute/tests/exit_rules.rs diff --git a/crates/promptforge-core/src/execute/tests/input.rs b/crates/promptforge-api/src/execute/tests/input.rs similarity index 99% rename from crates/promptforge-core/src/execute/tests/input.rs rename to crates/promptforge-api/src/execute/tests/input.rs index 38ef6ff49..e620ab947 100644 --- a/crates/promptforge-core/src/execute/tests/input.rs +++ b/crates/promptforge-api/src/execute/tests/input.rs @@ -303,7 +303,7 @@ async fn an_uncaught_broker_failure_fails_the_run_typed() { #[tokio::test(flavor = "current_thread", start_paused = true)] async fn cancellation_interrupts_a_pending_input_wait() { use crate::cancel::CancelHandle; - use promptforge_core_support::cancel::scope; + use shared_promptforge_api::cancel::scope; use std::time::{Duration, Instant}; let md = input_prompt("user_input()\nreturn 'unreachable'"); diff --git a/crates/promptforge-core/src/execute/tests/lazy_prose.rs b/crates/promptforge-api/src/execute/tests/lazy_prose.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/lazy_prose.rs rename to crates/promptforge-api/src/execute/tests/lazy_prose.rs diff --git a/crates/promptforge-core/src/execute/tests/live_infer.rs b/crates/promptforge-api/src/execute/tests/live_infer.rs similarity index 94% rename from crates/promptforge-core/src/execute/tests/live_infer.rs rename to crates/promptforge-api/src/execute/tests/live_infer.rs index cd8167000..f7755b068 100644 --- a/crates/promptforge-core/src/execute/tests/live_infer.rs +++ b/crates/promptforge-api/src/execute/tests/live_infer.rs @@ -20,8 +20,7 @@ async fn live_h1_infer_runs_once() { let out = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), to_config(gatewayed(addr)), ) .await @@ -101,8 +100,7 @@ async fn shared_function_resolves_host_globals_when_called() { let out = super::super::run( &prompt, "later host value", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), to_config(silent()), ) .await @@ -131,9 +129,8 @@ async fn shared_library_calls_host_apis_at_load_time() { let out = super::super::run( &prompt, "load-time args", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &store, - to_config(silent()), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), + to_config(silent()).vfs(store.vfs().clone()), ) .await .expect("top-level shared host calls must succeed"); @@ -190,8 +187,7 @@ async fn captured_bindings_reach_section_call_and_fanout_vms() { let out = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &catalog), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &catalog), to_config(silent()), ) .await @@ -224,8 +220,7 @@ async fn live_h1_models_infer_resolves_the_default_model_without_touching_sys() let out = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), to_config(gatewayed(gateway.addr())), ) .await @@ -274,8 +269,7 @@ async fn nested_lua_infer_emits_a_model_turn_observation() { let out = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), to_config(RunOptions { execution: EXECUTION, observer: Arc::clone(&recorder) as Arc, @@ -338,8 +332,7 @@ async fn cancelled_nested_infer_does_not_report_model_turn_failed() { let error = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), RunConfig::new(EXECUTION) .observer(Arc::clone(&recorder) as Arc) .client(gateway_client(gateway.addr())) @@ -422,8 +415,7 @@ async fn live_h1_prose_infers_explicitly_and_var_accumulates_into_the_walk() { let out = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), to_config(gatewayed(addr)), ) .await @@ -458,8 +450,7 @@ async fn h1_and_h2_prose_each_infer_explicitly_in_source_order() { let out = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), to_config(gatewayed(gateway.addr())), ) .await @@ -508,8 +499,7 @@ async fn live_h1_chunk_keeps_sys_id_zero_and_the_first_walked_section_takes_one( let out = super::super::run( &prompt, "", - ResolutionContext::new(&picker, &models, &ToolCatalog::default()), - &TestStore::new(), + ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), to_config(silent()), ) .await diff --git a/crates/promptforge-core/src/execute/tests/local_tools.rs b/crates/promptforge-api/src/execute/tests/local_tools.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/local_tools.rs rename to crates/promptforge-api/src/execute/tests/local_tools.rs diff --git a/crates/promptforge-core/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs similarity index 99% rename from crates/promptforge-core/src/execute/tests/mod.rs rename to crates/promptforge-api/src/execute/tests/mod.rs index 33c3cb9dd..5443c92f2 100644 --- a/crates/promptforge-core/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -357,9 +357,8 @@ async fn run( super::run( &test.prompt, args, - ResolutionContext::new(&picker, &test.models, &tool_catalog), - store.vfs(), - run_config, + ResolutionContext::new(Some(&picker), &test.models, &tool_catalog), + run_config.vfs(store.vfs().clone()), ) .await .map_err(Error::from) @@ -406,9 +405,8 @@ async fn run_with_config( super::run( &test.prompt, "", - ResolutionContext::new(&picker, &test.models, &ToolCatalog::default()), - TestStore::new().vfs(), - configure(RunConfig::new(EXECUTION)), + ResolutionContext::new(Some(&picker), &test.models, &ToolCatalog::default()), + configure(RunConfig::new(EXECUTION)).vfs(TestStore::new().vfs().clone()), ) .await } @@ -1354,7 +1352,7 @@ impl Tool for SlowTool { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancel_during_in_flight_tool_call_returns_promptly() { use crate::cancel::CancelHandle; - use promptforge_core_support::cancel::scope; + use shared_promptforge_api::cancel::scope; use std::time::{Duration, Instant}; let gateway = ScriptedGateway::start(echo_then_text_script()).await; diff --git a/crates/promptforge-core/src/execute/tests/model_and_reply.rs b/crates/promptforge-api/src/execute/tests/model_and_reply.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/model_and_reply.rs rename to crates/promptforge-api/src/execute/tests/model_and_reply.rs diff --git a/crates/promptforge-core/src/execute/tests/models_loop.rs b/crates/promptforge-api/src/execute/tests/models_loop.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/models_loop.rs rename to crates/promptforge-api/src/execute/tests/models_loop.rs diff --git a/crates/promptforge-core/src/execute/tests/observations.rs b/crates/promptforge-api/src/execute/tests/observations.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/observations.rs rename to crates/promptforge-api/src/execute/tests/observations.rs diff --git a/crates/promptforge-core/src/execute/tests/scheduler.rs b/crates/promptforge-api/src/execute/tests/scheduler.rs similarity index 99% rename from crates/promptforge-core/src/execute/tests/scheduler.rs rename to crates/promptforge-api/src/execute/tests/scheduler.rs index d2aab4900..482542c54 100644 --- a/crates/promptforge-core/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api/src/execute/tests/scheduler.rs @@ -118,7 +118,7 @@ async fn nested_call_and_inference_run_end_to_end_on_a_current_thread_runtime() #[tokio::test(flavor = "current_thread")] async fn cancellation_while_suspended_on_infer_interrupts_the_run() { use crate::cancel::CancelHandle; - use promptforge_core_support::cancel::scope; + use shared_promptforge_api::cancel::scope; let gateway = ScriptedGateway::start(vec![resp_delayed_text( "too late", @@ -1292,7 +1292,7 @@ impl H1Resolution { } fn context(&self) -> ResolutionContext<'_> { - ResolutionContext::new(&self.picker, &self.models, &self.tools) + ResolutionContext::new(Some(&self.picker), &self.models, &self.tools) } } @@ -2102,7 +2102,7 @@ async fn pre_cancelled_fanout_returns_interrupted() { // fanout entered under an already-cancelled handle fails the run with // Error::Interrupted instead of running the arms. use crate::cancel::CancelHandle; - use promptforge_core_support::cancel::scope; + use shared_promptforge_api::cancel::scope; let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ # Fanout\n\n\ @@ -2979,7 +2979,7 @@ async fn cancellation_while_suspended_in_a_fanout_arm_interrupts_the_run() { // 30-second answers and the timeout guard prove the aborted I/O is // never awaited. use crate::cancel::CancelHandle; - use promptforge_core_support::cancel::scope; + use shared_promptforge_api::cancel::scope; let gateway = ScriptedGateway::start(vec![resp_delayed_text( "too late", @@ -3335,7 +3335,7 @@ impl Tool for SignallingSlowTool { #[tokio::test(flavor = "current_thread", start_paused = true)] async fn cancellation_interrupts_a_slow_script_tools_call() { use crate::cancel::CancelHandle; - use promptforge_core_support::cancel::scope; + use shared_promptforge_api::cancel::scope; let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ # ToolCall\n\n\ diff --git a/crates/promptforge-core/src/execute/tests/tool_loop.rs b/crates/promptforge-api/src/execute/tests/tool_loop.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/tool_loop.rs rename to crates/promptforge-api/src/execute/tests/tool_loop.rs diff --git a/crates/promptforge-core/src/execute/tests/tool_scoping.rs b/crates/promptforge-api/src/execute/tests/tool_scoping.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/tool_scoping.rs rename to crates/promptforge-api/src/execute/tests/tool_scoping.rs diff --git a/crates/promptforge-core/src/execute/tests/unified_pipeline.rs b/crates/promptforge-api/src/execute/tests/unified_pipeline.rs similarity index 100% rename from crates/promptforge-core/src/execute/tests/unified_pipeline.rs rename to crates/promptforge-api/src/execute/tests/unified_pipeline.rs diff --git a/crates/promptforge-core/src/execute/tool_loop.rs b/crates/promptforge-api/src/execute/tool_loop.rs similarity index 99% rename from crates/promptforge-core/src/execute/tool_loop.rs rename to crates/promptforge-api/src/execute/tool_loop.rs index aeaf3614e..f90b81cfd 100644 --- a/crates/promptforge-core/src/execute/tool_loop.rs +++ b/crates/promptforge-api/src/execute/tool_loop.rs @@ -21,7 +21,7 @@ use std::collections::BTreeMap; use std::num::NonZeroU32; use std::sync::atomic::AtomicU32; -use promptforge_core_support::events::{CallMetrics, ToolCallEvent}; +use shared_promptforge_api::events::{CallMetrics, ToolCallEvent}; use crate::cancel; use crate::client::{ diff --git a/crates/promptforge-core/src/execute/tools.rs b/crates/promptforge-api/src/execute/tools.rs similarity index 100% rename from crates/promptforge-core/src/execute/tools.rs rename to crates/promptforge-api/src/execute/tools.rs diff --git a/crates/promptforge-core/src/fanout/arm.rs b/crates/promptforge-api/src/fanout/arm.rs similarity index 100% rename from crates/promptforge-core/src/fanout/arm.rs rename to crates/promptforge-api/src/fanout/arm.rs diff --git a/crates/promptforge-core/src/fanout/mod.rs b/crates/promptforge-api/src/fanout/mod.rs similarity index 100% rename from crates/promptforge-core/src/fanout/mod.rs rename to crates/promptforge-api/src/fanout/mod.rs diff --git a/crates/promptforge-core/src/fanout/tests.rs b/crates/promptforge-api/src/fanout/tests.rs similarity index 100% rename from crates/promptforge-core/src/fanout/tests.rs rename to crates/promptforge-api/src/fanout/tests.rs diff --git a/crates/promptforge-core/src/input.rs b/crates/promptforge-api/src/input.rs similarity index 96% rename from crates/promptforge-core/src/input.rs rename to crates/promptforge-api/src/input.rs index 69386e756..32631e28b 100644 --- a/crates/promptforge-core/src/input.rs +++ b/crates/promptforge-api/src/input.rs @@ -16,7 +16,7 @@ //! unavailable-fallback policy, and a broker error is the failure policy, //! raising a typed [`RunErrorKind::Input`](crate::RunErrorKind::Input) //! failure at the Lua call site. Waits and responses are recorded through -//! the run's [`Observer`](crate::observe::Observer) - a wait-opened observation and +//! the run's [`Observer`](shared_promptforge_api::observe::Observer) - a wait-opened observation and //! a byte-exact `on_user_input` report - without any replay machinery. use std::fmt; @@ -61,7 +61,7 @@ impl InputError { /// /// # Examples /// ``` - /// use promptforge_core::input::InputError; + /// use promptforge_api::input::InputError; /// /// let error = InputError::message("the input device is gone"); /// assert_eq!(error.to_string(), "the input device is gone"); @@ -78,7 +78,7 @@ impl InputError { /// /// # Examples /// ``` - /// use promptforge_core::input::InputError; + /// use promptforge_api::input::InputError; /// /// let cause = std::io::Error::other("socket reset"); /// let error = InputError::with_source("the input device is gone", cause); diff --git a/crates/promptforge-core/src/lib.rs b/crates/promptforge-api/src/lib.rs similarity index 62% rename from crates/promptforge-core/src/lib.rs rename to crates/promptforge-api/src/lib.rs index cae32bf33..dcfabd39c 100644 --- a/crates/promptforge-core/src/lib.rs +++ b/crates/promptforge-api/src/lib.rs @@ -5,11 +5,16 @@ //! [`client`] that talks to an `OpenAI`-compatible chat completions endpoint, and //! [`execute`] that runs H1 once with live resolution before walking sections //! top to bottom (fall-through) and -//! returns the run's result. [`observe`] is the seam through which a run -//! reports its progress, for a caller that wants to watch a long run in -//! flight; [`execute::run`] takes an [`execute::RunConfig`] carrying the -//! [`observe::Observer`] the correlated report records go to, and -//! [`observe::NullObserver`] is what a caller wanting silence passes. +//! returns the run's result. The host-facing vocabulary a run is configured +//! with - the progress observer, the model and tool catalogs, the tool +//! contract - lives in the `shared-promptforge-api` crate +//! (`shared_promptforge_api::observe`, `shared_promptforge_api::models`, +//! `shared_promptforge_api::tools`), and the store handle a host seeds or +//! extracts comes from `shared-vfs` and `promptforge-vfs`. +//! [`execute::run`] takes an [`execute::RunConfig`] carrying the +//! observer the correlated report records go to, and +//! `shared_promptforge_api::observe::NullObserver` is what a caller wanting +//! silence passes. //! [`debug::DebugCapture`] is an opt-in raw request/response seam on the same //! config; production hosts leave it unset. //! @@ -22,8 +27,8 @@ //! Detect a promptforge source and parse it into a [`Prompt`]: //! //! ``` -//! use promptforge_core::{Prompt, promptforge_version}; -//! use promptforge_core::observe::NullObserver; +//! use promptforge_api::{Prompt, promptforge_version}; +//! use shared_promptforge_api::observe::NullObserver; //! //! let source = "---\nname: greeter\ndescription: says hi\npromptforge: 0\n---\n\n# Greeter\n\n## Say hi\n\nSay hello.\n\n```lua\nreturn models.infer(prose)\n```\n"; //! @@ -34,32 +39,32 @@ //! let prompt = Prompt::parse(source, "doc-example", &NullObserver::default())?; //! assert_eq!(prompt.title(), "Greeter"); //! assert_eq!(prompt.sections()[0].name(), "Say hi"); -//! # Ok::<(), promptforge_core::ParseError>(()) +//! # Ok::<(), promptforge_api::ParseError>(()) //! ``` //! -//! Executing a parsed prompt goes through [`run`] with a [`RunConfig`], a -//! [`ResolutionContext`] (picker, model catalog, and tool catalog), and a -//! VFS handle; that path can perform gateway I/O, so it is shown as `no_run`: +//! Executing a parsed prompt goes through [`run`] with a [`RunConfig`] and a +//! [`ResolutionContext`] (an optional picker, a model catalog, and a tool +//! catalog); the store handle rides on the config, defaulting to the stock +//! in-memory mount. That path can perform gateway I/O, so it is shown as +//! `no_run`: //! //! ```no_run //! # async fn example() -> Result<(), Box> { -//! use promptforge_core::{Prompt, ResolutionContext, RunConfig, run}; -//! use promptforge_core::model::ModelCatalog; -//! use promptforge_core::observe::NullObserver; -//! use promptforge_core::tools::ToolCatalog; -//! use promptforge_tool_picker::{Catalog, Config, ToolPicker}; +//! use promptforge_api::{Prompt, ResolutionContext, RunConfig, run}; +//! use shared_promptforge_api::models::ModelCatalog; +//! use shared_promptforge_api::observe::NullObserver; +//! use shared_promptforge_api::tools::ToolCatalog; //! //! let source = "---\nname: greeter\ndescription: says hi\npromptforge: 0\n---\n\n# Greeter\n\n## Say hi\n\nSay hello.\n\n```lua\nreturn models.infer(prose)\n```\n"; //! let prompt = Prompt::parse(source, "run-example", &NullObserver::default())?; //! -//! let picker = ToolPicker::build(Catalog::new(Vec::new()), Config::default())?; +//! // Capability-free agents pass no picker. //! let models = ModelCatalog::empty(); //! let tools = ToolCatalog::new(&[])?; //! let answer = run( //! &prompt, //! "", -//! ResolutionContext::new(&picker, &models, &tools), -//! &promptforge_vfs::empty(), +//! ResolutionContext::new(None, &models, &tools), //! RunConfig::new("run-example"), //! ) //! .await?; @@ -67,7 +72,7 @@ //! # Ok(()) //! # } //! ``` - +//! pub(crate) mod cancel; pub mod client; pub mod debug; @@ -76,22 +81,20 @@ pub mod execute; pub(crate) mod fanout; pub mod input; pub(crate) mod lua; -pub mod model; -pub mod observe; +pub(crate) mod model; +pub(crate) mod observe; pub mod parser; mod resolve; -pub mod store; +pub(crate) mod store; pub(crate) mod subst; #[cfg(test)] pub(crate) mod test_support; -pub mod tools; +pub(crate) mod tools; pub(crate) mod untrusted; pub(crate) use crate::error::{Error, Result}; pub(crate) use crate::tools::NearDuplicateDiagnostic; -pub use promptforge_core_support::cancel::CancelHandle; - +pub use crate::client::{CompletionError, CompletionErrorKind}; pub use crate::execute::{ResolutionContext, RunConfig, RunError, RunErrorKind, RunLimits, run}; -pub use crate::model::{CompletionError, CompletionErrorKind}; pub use crate::parser::{ParseError, ParseErrorKind, Prompt, promptforge_version}; diff --git a/crates/promptforge-core/src/lua.rs b/crates/promptforge-api/src/lua.rs similarity index 94% rename from crates/promptforge-core/src/lua.rs rename to crates/promptforge-api/src/lua.rs index c79d090e7..d7e9b2a99 100644 --- a/crates/promptforge-core/src/lua.rs +++ b/crates/promptforge-api/src/lua.rs @@ -9,7 +9,7 @@ //! even an unbounded loop aborts promptly once the host cancels. //! //! The implementation lives in the `promptforge-lua` crate and is re-exported -//! here unchanged, so existing `promptforge_core::lua::*` paths keep working. +//! here unchanged, so existing `promptforge_api::lua::*` paths keep working. pub(crate) use promptforge_lua::{ CoroStep, LiveBindingProducer, LuaBlockResult, LuaFanoutResult, LuaProgram, MessageContent, diff --git a/crates/promptforge-core/src/lua/coro_tests.rs b/crates/promptforge-api/src/lua/coro_tests.rs similarity index 98% rename from crates/promptforge-core/src/lua/coro_tests.rs rename to crates/promptforge-api/src/lua/coro_tests.rs index 57bef14d1..31ac8013f 100644 --- a/crates/promptforge-core/src/lua/coro_tests.rs +++ b/crates/promptforge-api/src/lua/coro_tests.rs @@ -1,7 +1,7 @@ //! The coroutine shim protocol tests: the yield shims installed on a //! scheduler-mode section VM produce well-formed protocol requests. //! -//! These live in `promptforge-core` (not in `promptforge-lua`) because the +//! These live in `promptforge-api` (not in `promptforge-lua`) because the //! real setup path they exercise is the executor's `section_vm` composition, //! which stays with the executor to keep the dependency one-directional. @@ -21,8 +21,8 @@ use crate::model::{ModelBinding, ModelId, ModelSet}; use crate::observe::{NullObserver, Observer}; use crate::tools::{Tool, ToolError, ToolId, ToolOutput}; use crate::untrusted::GuardNonce; -use promptforge_core_support::cancel::scope; use promptforge_model_client::model::ModelInvocation; +use shared_promptforge_api::cancel::scope; fn test_models() -> ModelSet { ModelSet { @@ -403,7 +403,7 @@ fn a_traceback_through_a_shim_shows_unmapped_impl_frames() { .expect_err("the reassigned var fails the snapshot"); let raw = error.to_string(); assert!( - raw.contains("crates/promptforge-core/src/lua/__impl_coro.lua:"), + raw.contains("crates/promptforge-api/src/lua/__impl_coro.lua:"), "the shim frame renders as a verbatim file:line: {raw}" ); assert!( @@ -416,7 +416,7 @@ fn a_traceback_through_a_shim_shows_unmapped_impl_frames() { ); let mapped = program.map_runtime_error(&error).to_string(); assert!( - mapped.contains("crates/promptforge-core/src/lua/__impl_coro.lua:"), + mapped.contains("crates/promptforge-api/src/lua/__impl_coro.lua:"), "the line mapper leaves the shim frame unmapped: {mapped}" ); assert!( @@ -534,7 +534,7 @@ fn at_named_chunk_errors_render_verbatim_through_resume() { let vm = scheduler_vm(&ModelSet::default(), None); let program = LuaProgram::compile_internal( "local x = nil\nreturn x.field", - "@crates/promptforge-core/src/lua/__impl_probe.lua", + "@crates/promptforge-api/src/lua/__impl_probe.lua", ) .expect("the probe compiles"); let error = match vm.start_block_coro(&program) { @@ -543,7 +543,7 @@ fn at_named_chunk_errors_render_verbatim_through_resume() { }; let raw = error.to_string(); assert!( - raw.contains("crates/promptforge-core/src/lua/__impl_probe.lua:2:"), + raw.contains("crates/promptforge-api/src/lua/__impl_probe.lua:2:"), "the error renders as a verbatim file:line: {raw}" ); assert!( diff --git a/crates/promptforge-core/src/model.rs b/crates/promptforge-api/src/model.rs similarity index 63% rename from crates/promptforge-core/src/model.rs rename to crates/promptforge-api/src/model.rs index 16c67b570..3df76d602 100644 --- a/crates/promptforge-core/src/model.rs +++ b/crates/promptforge-api/src/model.rs @@ -9,18 +9,18 @@ //! that omit `models.use`. Model-facing sections with neither binding fail with //! a model-binding failure surfaced through [`crate::RunError`]. //! -//! The implementation lives in the `promptforge-model-client` crate and is -//! re-exported here unchanged, so existing `promptforge_core::model::*` paths -//! keep working. +//! The implementation lives in the `promptforge-model-client` crate. This +//! module is the crate-internal import surface for it; hosts name the model +//! vocabulary through `shared-promptforge-api`'s `models` module and the +//! completion error types through [`crate::client`]. -pub use promptforge_model_client::model::{ - CompletionError, CompletionErrorKind, CompletionOptions, ModelCatalog, ModelCatalogError, - ModelDescriptor, ModelId, ModelIdError, TemperatureError, ThinkingMode, fetch_model_catalog, -}; pub(crate) use promptforge_model_client::model::{ - ModelBindOpts, ModelBinding, ModelResolver, ModelSet, ModelView, PickerModelResolver, - ResolvedModel, + CompletionOptions, ModelBindOpts, ModelBinding, ModelCatalog, ModelId, ModelResolver, ModelSet, + ModelView, PickerModelResolver, ResolvedModel, }; +#[cfg(test)] +pub(crate) use promptforge_model_client::model::{ModelDescriptor, ThinkingMode}; + #[cfg(test)] mod tests; diff --git a/crates/promptforge-core/src/model/tests/always.rs b/crates/promptforge-api/src/model/tests/always.rs similarity index 100% rename from crates/promptforge-core/src/model/tests/always.rs rename to crates/promptforge-api/src/model/tests/always.rs diff --git a/crates/promptforge-core/src/model/tests/integration.rs b/crates/promptforge-api/src/model/tests/integration.rs similarity index 100% rename from crates/promptforge-core/src/model/tests/integration.rs rename to crates/promptforge-api/src/model/tests/integration.rs diff --git a/crates/promptforge-core/src/model/tests/mod.rs b/crates/promptforge-api/src/model/tests/mod.rs similarity index 98% rename from crates/promptforge-core/src/model/tests/mod.rs rename to crates/promptforge-api/src/model/tests/mod.rs index 9c9a60e82..5998c4365 100644 --- a/crates/promptforge-core/src/model/tests/mod.rs +++ b/crates/promptforge-api/src/model/tests/mod.rs @@ -13,7 +13,7 @@ use crate::tools::ToolCatalog; use crate::untrusted::GuardNonce; use crate::{Error, Result}; use promptforge_model_client::Error as GatewayClientError; -use promptforge_model_client::model::ModelInvocation; +use promptforge_model_client::model::{ModelCatalogFiltered, ModelInvocation}; use serde_json::json; const EXECUTION: &str = "model-bind-test"; diff --git a/crates/promptforge-core/src/observe.rs b/crates/promptforge-api/src/observe.rs similarity index 90% rename from crates/promptforge-core/src/observe.rs rename to crates/promptforge-api/src/observe.rs index 9ce6e1090..429bea203 100644 --- a/crates/promptforge-core/src/observe.rs +++ b/crates/promptforge-api/src/observe.rs @@ -5,13 +5,11 @@ //! never consulted for a decision. [`NullObserver`] provides silence without //! a second execution path. //! -//! The implementation lives in the `promptforge-core-support` crate and is -//! re-exported here unchanged, so existing `promptforge_core::observe::*` -//! paths keep working. +//! The implementation lives in the `shared-promptforge-api` crate. This +//! module is the crate-internal import surface for it; hosts name the +//! observation vocabulary through `shared_promptforge_api::observe`. -pub use promptforge_core_support::observe::{NullObserver, Observation, Observer}; - -pub(crate) use promptforge_core_support::observe::detail; +pub(crate) use shared_promptforge_api::observe::{NullObserver, Observation, Observer, detail}; #[cfg(test)] mod tests { diff --git a/crates/promptforge-core/src/parser.rs b/crates/promptforge-api/src/parser.rs similarity index 94% rename from crates/promptforge-core/src/parser.rs rename to crates/promptforge-api/src/parser.rs index 08f264ace..1f565e6e7 100644 --- a/crates/promptforge-core/src/parser.rs +++ b/crates/promptforge-api/src/parser.rs @@ -17,7 +17,7 @@ //! The parser does no execution. It turns bytes into a [`Prompt`] tree. //! //! The implementation lives in the `promptforge-parser` crate and is -//! re-exported here unchanged, so existing `promptforge_core::parser::*` +//! re-exported here unchanged, so existing `promptforge_api::parser::*` //! paths keep working. pub use promptforge_parser::{ diff --git a/crates/promptforge-core/src/resolve.rs b/crates/promptforge-api/src/resolve.rs similarity index 93% rename from crates/promptforge-core/src/resolve.rs rename to crates/promptforge-api/src/resolve.rs index e6250db9c..39b1710d1 100644 --- a/crates/promptforge-core/src/resolve.rs +++ b/crates/promptforge-api/src/resolve.rs @@ -18,10 +18,10 @@ use crate::{Error, Result}; /// Run-scoped capability resolver and live H1 binding producer. pub(crate) struct RuntimeResolution<'a> { - tool_resolver: PickerResolver<'a, ToolPicker>, + tool_resolver: PickerResolver<'a, dyn DecisionSource>, tools: &'a ToolCatalog, models: &'a ModelCatalog, - base_picker: &'a ToolPicker, + base_picker: Option<&'a ToolPicker>, producer: LiveBindingProducer, } @@ -38,19 +38,27 @@ impl<'a> RuntimeResolution<'a> { /// model index is built on demand, when a `models.bind`'s constraints are /// known, so the redundant full-catalog index is never materialized. /// + /// A picker-less run (`picker: None`) is the capability-free posture: + /// every executed `tools.bind` or `models.bind` fails as a binding error + /// naming the missing picker. + /// /// `tool_set` and `model_set` are the run's shared sets: executed /// `tools.bind`/`tools.always` and `models.bind`/`models.default` calls /// write through them, and the run context reads the same allocations /// through its views. pub(crate) fn new( - picker: &'a ToolPicker, + picker: Option<&'a ToolPicker>, tools: &'a ToolCatalog, models: &'a ModelCatalog, tool_set: Arc>, model_set: Arc>, ) -> Self { + let source: &dyn DecisionSource = match picker { + Some(picker) => picker, + None => &NoPicker, + }; Self { - tool_resolver: PickerResolver::new(picker), + tool_resolver: PickerResolver::new(source), tools, models, base_picker: picker, @@ -98,9 +106,16 @@ impl ModelResolver for RuntimeResolution<'_> { capability: description.to_owned(), }); } + // A picker-less run cannot bind a described model. + let Some(picker) = self.base_picker else { + return Err(GatewayClientError::ModelBind { + capability: description.to_owned(), + detail: "the run was given no tool picker".to_owned(), + }); + }; // The filtered model index is built here, from the base embedder, over // just the descriptors that satisfy the bind's constraints (F7). - PickerModelResolver::new(self.models, self.base_picker).resolve(description, opts) + PickerModelResolver::new(self.models, picker).resolve(description, opts) } } @@ -124,6 +139,9 @@ enum CachedDecision { /// The picker returned an outcome this resolver does not model (a defensive /// catch-all; no dependency error to preserve). Unrecognized, + /// The run was given no picker: every capability bind fails, naming the + /// missing picker. + NoPicker, } /// Converts a borrowed picker descriptor to a core-owned [`ToolId`]. @@ -171,6 +189,10 @@ impl CachedDecision { capability: capability.to_owned(), detail: "the picker reported an unrecognized outcome".to_owned(), }), + Self::NoPicker => Err(promptforge_lua::Error::Bind { + capability: capability.to_owned(), + detail: "the run was given no tool picker".to_owned(), + }), } } } @@ -213,6 +235,24 @@ impl DecisionSource for ToolPicker { } } +/// The decision source behind a picker-less run: every tool capability fails +/// as an unbound bind, and the near-duplicate scan is vacuous (no bind ever +/// succeeds, so no scope is ever analyzed). +struct NoPicker; + +impl DecisionSource for NoPicker { + fn decide(&self, _capability: &str) -> CachedDecision { + CachedDecision::NoPicker + } + + fn near_duplicates( + &self, + _ids: &[PickerToolId], + ) -> std::result::Result, SharedSource> { + Ok(Vec::new()) + } +} + /// One cached, single-flight decision cell for a capability (F1). type DecisionCell = Arc>; diff --git a/crates/promptforge-core/src/store.rs b/crates/promptforge-api/src/store.rs similarity index 69% rename from crates/promptforge-core/src/store.rs rename to crates/promptforge-api/src/store.rs index 4efabacd1..4efb60631 100644 --- a/crates/promptforge-core/src/store.rs +++ b/crates/promptforge-api/src/store.rs @@ -2,7 +2,7 @@ //! //! A prompt run keeps its bulk state in virtual files addressed by logical //! string paths. The run's [`VfsRef`] handle carries the store mount; the -//! [`Store`] facade (behind the [`StoreExt`] extension trait's +//! [`Store`] facade (behind the `StoreExt` extension trait's //! `vfs.store(&access)` call shape) scopes logical paths onto it, and every //! operation is attributed to the [`Access`] capability's identity, so a //! conflicting operation by a second live identity surfaces as @@ -15,9 +15,11 @@ //! `untrusted` Lua global). Edits are anchor-based ([`Store::str_replace`]) //! rather than offset-based, the shape that works for a model. //! -//! The implementation lives in the `promptforge-store` crate and is -//! re-exported here unchanged, so existing `promptforge_core::store::*` -//! paths keep working. +//! The implementation lives in the `promptforge-store` and `shared-vfs` +//! crates. This module is the crate-internal import surface for them; hosts +//! that seed or extract the store depend on `shared-vfs` directly. -pub use promptforge_store::{PathReason, Store, StoreError, StoreErrorKind, StoreExt}; -pub use shared_vfs::{Access, VfsRef}; +#[cfg(test)] +pub(crate) use promptforge_store::StoreExt; +pub(crate) use promptforge_store::{Store, StoreError}; +pub(crate) use shared_vfs::{Access, VfsRef}; diff --git a/crates/promptforge-core/src/subst.rs b/crates/promptforge-api/src/subst.rs similarity index 100% rename from crates/promptforge-core/src/subst.rs rename to crates/promptforge-api/src/subst.rs diff --git a/crates/promptforge-core/src/test_support.rs b/crates/promptforge-api/src/test_support.rs similarity index 100% rename from crates/promptforge-core/src/test_support.rs rename to crates/promptforge-api/src/test_support.rs diff --git a/crates/promptforge-core/src/tools.rs b/crates/promptforge-api/src/tools.rs similarity index 60% rename from crates/promptforge-core/src/tools.rs rename to crates/promptforge-api/src/tools.rs index da77d6b63..bf61186ad 100644 --- a/crates/promptforge-core/src/tools.rs +++ b/crates/promptforge-api/src/tools.rs @@ -2,22 +2,24 @@ //! //! Some tools run locally in this process (for example fetching and rendering a //! web page), while others proxy through the gateway so a shared credential -//! never leaves the server. Both kinds share the [`Tool`] trait so the executor +//! never leaves the server. Both kinds share one `Tool` trait so the executor //! can dispatch them uniformly. Stable identity is separate from the wire name //! used by the current model transport. //! -//! The runtime-agnostic contract vocabulary ([`Tool`], [`ToolCatalog`], -//! [`ToolId`], the output and error types) lives in the `promptforge-tools` -//! crate and is re-exported here unchanged, so existing -//! `promptforge_core::tools::*` paths keep working. The concrete `WebSearch` -//! provider lives in the `promptforge-web-search` crate and is re-exported -//! here under its historical path for the same reason. +//! The runtime-agnostic contract vocabulary (the `Tool` trait, +//! [`ToolCatalog`], [`ToolId`], the output and error types) lives in the +//! `shared-promptforge-api` crate's `tools` module, and the concrete +//! `WebSearch` provider lives in the `promptforge-web-search` crate. This +//! module is the crate-internal import surface for both; hosts name the +//! contract through `shared_promptforge_api::tools`. -pub use promptforge_tools::{ - OutputTrust, Tool, ToolCatalog, ToolCatalogError, ToolCatalogErrorKind, ToolError, - ToolErrorKind, ToolId, ToolIdError, ToolIdErrorKind, ToolOutput, +#[cfg(test)] +pub(crate) use promptforge_web_search::WebSearch; +#[cfg(test)] +pub(crate) use shared_promptforge_api::tools::{ + OutputTrust, Tool, ToolError, ToolErrorKind, ToolOutput, }; -pub use promptforge_web_search::WebSearch; +pub(crate) use shared_promptforge_api::tools::{ToolCatalog, ToolId}; /// Diagnostics for two semantic near-duplicates exposed in one model turn. /// diff --git a/crates/promptforge-core/src/tools/tests.rs b/crates/promptforge-api/src/tools/tests.rs similarity index 88% rename from crates/promptforge-core/src/tools/tests.rs rename to crates/promptforge-api/src/tools/tests.rs index 0b1a288fe..b3b46991d 100644 --- a/crates/promptforge-core/src/tools/tests.rs +++ b/crates/promptforge-api/src/tools/tests.rs @@ -1,5 +1,6 @@ -//! Regression coverage for the `promptforge_core::tools` compatibility -//! re-exports: the contract vocabulary moved to `promptforge-tools`, and these +//! Regression coverage for the `promptforge_api::tools` compatibility +//! re-exports: the contract vocabulary lives in `shared-promptforge-api`'s +//! `tools` module, and these //! tests pin that the re-exported path is the same trait and types, not a //! lookalike. @@ -10,7 +11,7 @@ use serde_json::{Value, json}; // The fixture implements the trait through the defining crate's path on // purpose: if the re-export ever stopped being the same trait, the `Arc` coercions below would fail to compile. -use promptforge_tools::{Tool as ContractTool, ToolError, ToolId, ToolOutput}; +use shared_promptforge_api::tools::{Tool as ContractTool, ToolError, ToolId, ToolOutput}; use crate::tools::{Tool, ToolCatalog}; @@ -70,10 +71,10 @@ fn reexported_types_are_the_contract_types() { // A function written against the defining crate's types accepts values // produced through the re-exported path only when both names denote the // same type. - fn takes_contract_id(id: &promptforge_tools::ToolId) -> &str { + fn takes_contract_id(id: &shared_promptforge_api::tools::ToolId) -> &str { id.name() } - fn takes_contract_catalog(catalog: &promptforge_tools::ToolCatalog) -> usize { + fn takes_contract_catalog(catalog: &shared_promptforge_api::tools::ToolCatalog) -> usize { catalog.tools().len() } diff --git a/crates/promptforge-api/src/untrusted.rs b/crates/promptforge-api/src/untrusted.rs new file mode 100644 index 000000000..92c1b8abd --- /dev/null +++ b/crates/promptforge-api/src/untrusted.rs @@ -0,0 +1,7 @@ +//! Guard-wrapping for untrusted external data. +//! +//! The implementation lives in the `shared-promptforge-api` crate and is +//! re-exported here unchanged, so existing `promptforge_api::untrusted::*` +//! paths keep working. + +pub(crate) use shared_promptforge_api::untrusted::GuardNonce; diff --git a/crates/promptforge-core/tests/prompts/execution/fanout-arm-failure.md b/crates/promptforge-api/tests/prompts/execution/fanout-arm-failure.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/fanout-arm-failure.md rename to crates/promptforge-api/tests/prompts/execution/fanout-arm-failure.md diff --git a/crates/promptforge-core/tests/prompts/execution/fanout-basic.md b/crates/promptforge-api/tests/prompts/execution/fanout-basic.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/fanout-basic.md rename to crates/promptforge-api/tests/prompts/execution/fanout-basic.md diff --git a/crates/promptforge-core/tests/prompts/execution/fanout-cross-arm-append.md b/crates/promptforge-api/tests/prompts/execution/fanout-cross-arm-append.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/fanout-cross-arm-append.md rename to crates/promptforge-api/tests/prompts/execution/fanout-cross-arm-append.md diff --git a/crates/promptforge-core/tests/prompts/execution/fanout-epilog.md b/crates/promptforge-api/tests/prompts/execution/fanout-epilog.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/fanout-epilog.md rename to crates/promptforge-api/tests/prompts/execution/fanout-epilog.md diff --git a/crates/promptforge-core/tests/prompts/execution/fanout-store-writes.md b/crates/promptforge-api/tests/prompts/execution/fanout-store-writes.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/fanout-store-writes.md rename to crates/promptforge-api/tests/prompts/execution/fanout-store-writes.md diff --git a/crates/promptforge-core/tests/prompts/execution/log-checkpoints.md b/crates/promptforge-api/tests/prompts/execution/log-checkpoints.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/log-checkpoints.md rename to crates/promptforge-api/tests/prompts/execution/log-checkpoints.md diff --git a/crates/promptforge-core/tests/prompts/execution/prologue-return.md b/crates/promptforge-api/tests/prompts/execution/prologue-return.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/prologue-return.md rename to crates/promptforge-api/tests/prompts/execution/prologue-return.md diff --git a/crates/promptforge-core/tests/prompts/execution/real-text.md b/crates/promptforge-api/tests/prompts/execution/real-text.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/real-text.md rename to crates/promptforge-api/tests/prompts/execution/real-text.md diff --git a/crates/promptforge-core/tests/prompts/execution/real-tool-call.md b/crates/promptforge-api/tests/prompts/execution/real-tool-call.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/real-tool-call.md rename to crates/promptforge-api/tests/prompts/execution/real-tool-call.md diff --git a/crates/promptforge-core/tests/prompts/execution/reply-nil-section-one.md b/crates/promptforge-api/tests/prompts/execution/reply-nil-section-one.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/reply-nil-section-one.md rename to crates/promptforge-api/tests/prompts/execution/reply-nil-section-one.md diff --git a/crates/promptforge-core/tests/prompts/execution/store-fallthrough.md b/crates/promptforge-api/tests/prompts/execution/store-fallthrough.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/store-fallthrough.md rename to crates/promptforge-api/tests/prompts/execution/store-fallthrough.md diff --git a/crates/promptforge-core/tests/prompts/execution/store-triad.md b/crates/promptforge-api/tests/prompts/execution/store-triad.md similarity index 100% rename from crates/promptforge-core/tests/prompts/execution/store-triad.md rename to crates/promptforge-api/tests/prompts/execution/store-triad.md diff --git a/crates/promptforge-core/tests/prompts/invalid/item-outside-fanout.md b/crates/promptforge-api/tests/prompts/invalid/item-outside-fanout.md similarity index 100% rename from crates/promptforge-core/tests/prompts/invalid/item-outside-fanout.md rename to crates/promptforge-api/tests/prompts/invalid/item-outside-fanout.md diff --git a/crates/promptforge-core/tests/prompts/invalid/list-h3-non-list-content.md b/crates/promptforge-api/tests/prompts/invalid/list-h3-non-list-content.md similarity index 100% rename from crates/promptforge-core/tests/prompts/invalid/list-h3-non-list-content.md rename to crates/promptforge-api/tests/prompts/invalid/list-h3-non-list-content.md diff --git a/crates/promptforge-core/tests/prompts/invalid/malformed-epilog.md b/crates/promptforge-api/tests/prompts/invalid/malformed-epilog.md similarity index 100% rename from crates/promptforge-core/tests/prompts/invalid/malformed-epilog.md rename to crates/promptforge-api/tests/prompts/invalid/malformed-epilog.md diff --git a/crates/promptforge-core/tests/prompts/invalid/missing-h1.md b/crates/promptforge-api/tests/prompts/invalid/missing-h1.md similarity index 100% rename from crates/promptforge-core/tests/prompts/invalid/missing-h1.md rename to crates/promptforge-api/tests/prompts/invalid/missing-h1.md diff --git a/crates/promptforge-core/tests/prompts/invalid/removed-lua-prompt.md b/crates/promptforge-api/tests/prompts/invalid/removed-lua-prompt.md similarity index 100% rename from crates/promptforge-core/tests/prompts/invalid/removed-lua-prompt.md rename to crates/promptforge-api/tests/prompts/invalid/removed-lua-prompt.md diff --git a/crates/promptforge-core/tests/prompts/invalid/reply-substitution-nil.md b/crates/promptforge-api/tests/prompts/invalid/reply-substitution-nil.md similarity index 100% rename from crates/promptforge-core/tests/prompts/invalid/reply-substitution-nil.md rename to crates/promptforge-api/tests/prompts/invalid/reply-substitution-nil.md diff --git a/crates/promptforge-core/tests/prompts/valid/minimal.md b/crates/promptforge-api/tests/prompts/valid/minimal.md similarity index 100% rename from crates/promptforge-core/tests/prompts/valid/minimal.md rename to crates/promptforge-api/tests/prompts/valid/minimal.md diff --git a/crates/promptforge-core/tests/prompts/valid/prologue-prose-epilog.md b/crates/promptforge-api/tests/prompts/valid/prologue-prose-epilog.md similarity index 100% rename from crates/promptforge-core/tests/prompts/valid/prologue-prose-epilog.md rename to crates/promptforge-api/tests/prompts/valid/prologue-prose-epilog.md diff --git a/crates/promptforge-core/tests/prompts/valid/shared-library.md b/crates/promptforge-api/tests/prompts/valid/shared-library.md similarity index 100% rename from crates/promptforge-core/tests/prompts/valid/shared-library.md rename to crates/promptforge-api/tests/prompts/valid/shared-library.md diff --git a/crates/promptforge-core/tests/suite/execution.rs b/crates/promptforge-api/tests/suite/execution.rs similarity index 99% rename from crates/promptforge-core/tests/suite/execution.rs rename to crates/promptforge-api/tests/suite/execution.rs index 435ce474b..4e9d7d44e 100644 --- a/crates/promptforge-core/tests/suite/execution.rs +++ b/crates/promptforge-api/tests/suite/execution.rs @@ -5,8 +5,8 @@ use std::collections::BTreeSet; use std::sync::Arc; -use promptforge_core::execute::RunErrorKind; -use promptforge_core::observe::Observer; +use promptforge_api::execute::RunErrorKind; +use shared_promptforge_api::observe::Observer; use super::support::{Record, Recorder, RunOptions, parse_execution_fixture, run, run_fixture}; diff --git a/crates/promptforge-core/tests/suite/fanout.rs b/crates/promptforge-api/tests/suite/fanout.rs similarity index 99% rename from crates/promptforge-core/tests/suite/fanout.rs rename to crates/promptforge-api/tests/suite/fanout.rs index eb84a3088..9cd6491ab 100644 --- a/crates/promptforge-core/tests/suite/fanout.rs +++ b/crates/promptforge-api/tests/suite/fanout.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; -use promptforge_core::execute::RunErrorKind; +use promptforge_api::execute::RunErrorKind; use shared_vfs::{Entry, ExecId, MemoryBackend, Stat, Vfs, VfsAccess, VfsError, VfsPath, VfsRef}; use super::support::{Record, run_fixture}; diff --git a/crates/promptforge-core/tests/suite/main.rs b/crates/promptforge-api/tests/suite/main.rs similarity index 100% rename from crates/promptforge-core/tests/suite/main.rs rename to crates/promptforge-api/tests/suite/main.rs diff --git a/crates/promptforge-core/tests/suite/parsing.rs b/crates/promptforge-api/tests/suite/parsing.rs similarity index 97% rename from crates/promptforge-core/tests/suite/parsing.rs rename to crates/promptforge-api/tests/suite/parsing.rs index 5f04a4a88..6fd70849e 100644 --- a/crates/promptforge-core/tests/suite/parsing.rs +++ b/crates/promptforge-api/tests/suite/parsing.rs @@ -3,8 +3,8 @@ use std::num::NonZeroU32; -use promptforge_core::observe::NullObserver; -use promptforge_core::parser::{LuaProgram, MaxToolIterations, ParseErrorKind, Prompt}; +use promptforge_api::parser::{LuaProgram, MaxToolIterations, ParseErrorKind, Prompt}; +use shared_promptforge_api::observe::NullObserver; struct ValidFixture { name: &'static str, diff --git a/crates/promptforge-core/tests/suite/shipped.rs b/crates/promptforge-api/tests/suite/shipped.rs similarity index 92% rename from crates/promptforge-core/tests/suite/shipped.rs rename to crates/promptforge-api/tests/suite/shipped.rs index b0b1b85cb..a37159d24 100644 --- a/crates/promptforge-core/tests/suite/shipped.rs +++ b/crates/promptforge-api/tests/suite/shipped.rs @@ -3,8 +3,8 @@ use std::fs; use std::path::{Path, PathBuf}; -use promptforge_core::observe::NullObserver; -use promptforge_core::parser::Prompt; +use promptforge_api::parser::Prompt; +use shared_promptforge_api::observe::NullObserver; const SHIPPED_PARSE: &str = "fixture-shipped-prompts"; diff --git a/crates/promptforge-core/tests/suite/support.rs b/crates/promptforge-api/tests/suite/support.rs similarity index 89% rename from crates/promptforge-core/tests/suite/support.rs rename to crates/promptforge-api/tests/suite/support.rs index a8bd2ffe3..a9026c61c 100644 --- a/crates/promptforge-core/tests/suite/support.rs +++ b/crates/promptforge-api/tests/suite/support.rs @@ -5,14 +5,14 @@ use std::sync::{Arc, Mutex}; -use promptforge_core::execute::{ResolutionContext, RunConfig, RunError, run as run_core}; -use promptforge_core::model::ModelCatalog; -use promptforge_core::observe::{Observation, Observer}; -use promptforge_core::parser::Prompt; -use promptforge_core::store::{StoreError, StoreExt, VfsRef}; +use promptforge_api::execute::{ResolutionContext, RunConfig, RunError, run as run_core}; +use promptforge_api::parser::Prompt; +use promptforge_store::{StoreError, StoreExt}; use promptforge_tool_picker::{Catalog, Config, ToolPicker}; -use promptforge_tools::{Tool, ToolCatalog}; -use shared_vfs::Origin; +use shared_promptforge_api::models::ModelCatalog; +use shared_promptforge_api::observe::{Observation, Observer}; +use shared_promptforge_api::tools::{Tool, ToolCatalog}; +use shared_vfs::{Origin, VfsRef}; /// One correlated observation: which execution and section emitted it, plus the /// rendered event detail the fixtures assert on. @@ -61,9 +61,10 @@ pub(super) async fn run( run_core( prompt, args, - ResolutionContext::new(&picker, &models, &tools), - vfs, - RunConfig::new(opts.execution).observer(opts.observer), + ResolutionContext::new(Some(&picker), &models, &tools), + RunConfig::new(opts.execution) + .observer(opts.observer) + .vfs(vfs.clone()), ) .await } diff --git a/crates/promptforge-core/tests/suite/vfs.rs b/crates/promptforge-api/tests/suite/vfs.rs similarity index 98% rename from crates/promptforge-core/tests/suite/vfs.rs rename to crates/promptforge-api/tests/suite/vfs.rs index 56659f264..9dce8898c 100644 --- a/crates/promptforge-core/tests/suite/vfs.rs +++ b/crates/promptforge-api/tests/suite/vfs.rs @@ -4,8 +4,8 @@ //! `vfs.store()`, run, extract the declared output, and charge a missing //! output to the prompt's promise as an explicit contract error. -use promptforge_core::parser::Prompt; -use promptforge_core::store::{Store, StoreError, StoreExt}; +use promptforge_api::parser::Prompt; +use promptforge_store::{Store, StoreError, StoreExt}; use shared_vfs::{HostBackend, Origin, VfsRef}; use super::support::{RunOptions, parse_execution_fixture, run, run_fixture}; @@ -91,7 +91,7 @@ fn offline_run( prompt: &Prompt, vfs: &VfsRef, execution: &'static str, -) -> impl std::future::Future> { +) -> impl std::future::Future> { let recorder = Arc::new(Recorder::default()); let prompt = prompt.clone(); let vfs = vfs.clone(); @@ -220,7 +220,7 @@ impl TempDir { .expect("the clock is after the epoch") .as_nanos(); let dir = std::env::temp_dir().join(format!( - "promptforge-core-vfs-{}-{unique}-{name}", + "promptforge-api-vfs-{}-{unique}-{name}", std::process::id(), )); std::fs::create_dir_all(&dir).expect("the temp dir creates"); diff --git a/crates/promptforge-core/src/cancel.rs b/crates/promptforge-core/src/cancel.rs deleted file mode 100644 index 8c810e8f4..000000000 --- a/crates/promptforge-core/src/cancel.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Cooperative cancellation for long-running execute paths. -//! -//! The implementation lives in the `promptforge-core-support` crate and is -//! re-exported here unchanged, so existing `promptforge_core::cancel::*` paths -//! keep working. - -pub(crate) use promptforge_core_support::cancel::{ - CancelHandle, current, is_cancelled, maybe_scope, wait_cancelled, -}; diff --git a/crates/promptforge-core/src/untrusted.rs b/crates/promptforge-core/src/untrusted.rs deleted file mode 100644 index 5a83ad770..000000000 --- a/crates/promptforge-core/src/untrusted.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Guard-wrapping for untrusted external data. -//! -//! The implementation lives in the `promptforge-core-support` crate and is -//! re-exported here unchanged, so existing `promptforge_core::untrusted::*` -//! paths keep working. - -pub(crate) use promptforge_core_support::untrusted::GuardNonce; diff --git a/crates/promptforge-lua/Cargo.toml b/crates/promptforge-lua/Cargo.toml index a6a4aca08..11ea9f75d 100644 --- a/crates/promptforge-lua/Cargo.toml +++ b/crates/promptforge-lua/Cargo.toml @@ -14,10 +14,9 @@ documentation = "https://cppalliance.github.io/promptforge/" [dependencies] mlua.workspace = true -promptforge-core-support.workspace = true +shared-promptforge-api.workspace = true promptforge-model-client.workspace = true promptforge-store.workspace = true -promptforge-tools.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/promptforge-lua/benches/surface.rs b/crates/promptforge-lua/benches/surface.rs index 3ceb59430..c95e92002 100644 --- a/crates/promptforge-lua/benches/surface.rs +++ b/crates/promptforge-lua/benches/surface.rs @@ -17,14 +17,14 @@ use std::num::NonZeroU32; use criterion::{Criterion, criterion_group, criterion_main}; -use promptforge_core_support::observe::NullObserver; -use promptforge_core_support::untrusted::GuardNonce; use promptforge_lua::{ LuaProgram, MessageContent, MessageRecord, MessageRole, SectionVm, ToolCallRecord, ToolSet, project_messages, }; use promptforge_model_client::model::ModelSet; use serde_json::json; +use shared_promptforge_api::observe::NullObserver; +use shared_promptforge_api::untrusted::GuardNonce; const EXECUTION: &str = "bench"; const SECTION: &str = "Bench"; diff --git a/crates/promptforge-lua/src/coro.rs b/crates/promptforge-lua/src/coro.rs index 910e64815..51cde52b4 100644 --- a/crates/promptforge-lua/src/coro.rs +++ b/crates/promptforge-lua/src/coro.rs @@ -20,7 +20,7 @@ use super::{Error, Lua, LuaProgram, Result, SharedSource, StdLib, var_snapshot_t /// The shim chunk's name: `@`-prefixed so PUC renders it verbatim as a file /// path, making unexpected shim errors clickable `file:line:` references. -const SHIM_CHUNK_NAME: &str = "@crates/promptforge-core/src/lua/__impl_coro.lua"; +const SHIM_CHUNK_NAME: &str = "@crates/promptforge-api/src/lua/__impl_coro.lua"; /// The shim source, embedded verbatim so chunk line 1 is file line 1. const SHIM_SOURCE: &str = include_str!("__impl_coro.lua"); diff --git a/crates/promptforge-lua/src/dispatch.rs b/crates/promptforge-lua/src/dispatch.rs index f8c309e23..2e577e73f 100644 --- a/crates/promptforge-lua/src/dispatch.rs +++ b/crates/promptforge-lua/src/dispatch.rs @@ -7,10 +7,10 @@ //! here - the crate every executor already depends on - is what stops //! dispatch semantics from forking. -use promptforge_core_support::cancel; -use promptforge_core_support::observe::{Observer, detail}; -use promptforge_core_support::untrusted::GuardNonce; -use promptforge_tools::OutputTrust; +use shared_promptforge_api::cancel; +use shared_promptforge_api::observe::{Observer, detail}; +use shared_promptforge_api::tools::OutputTrust; +use shared_promptforge_api::untrusted::GuardNonce; use crate::error::{Error, Result}; use crate::{ToolBinding, ToolCallCounts}; @@ -151,10 +151,10 @@ pub async fn dispatch_tool( mod tests { use std::sync::{Arc, Mutex}; - use promptforge_core_support::cancel::CancelHandle; - use promptforge_core_support::observe::{NullObserver, Observation}; - use promptforge_tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; use serde_json::json; + use shared_promptforge_api::cancel::CancelHandle; + use shared_promptforge_api::observe::{NullObserver, Observation}; + use shared_promptforge_api::tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; use super::*; diff --git a/crates/promptforge-lua/src/error.rs b/crates/promptforge-lua/src/error.rs index 125915193..954784530 100644 --- a/crates/promptforge-lua/src/error.rs +++ b/crates/promptforge-lua/src/error.rs @@ -1,17 +1,17 @@ //! The crate's internal error substrate. //! -//! [`Error`] mirrors the role `promptforge-core`'s substrate plays there: it +//! [`Error`] mirrors the role `promptforge-api`'s substrate plays there: it //! is never part of the documented API. The executor's public boundary -//! (`promptforge_core::RunError`) wraps and classifies core's own substrate, +//! (`promptforge_api::RunError`) wraps and classifies core's own substrate, //! which maps this one back variant-for-variant through //! `From`. The substrate is `#[doc(hidden)]` and -//! re-exported only so `promptforge-core` can perform that mapping verbatim; +//! re-exported only so `promptforge-api` can perform that mapping verbatim; //! it is not a stable API and is not marked `#[non_exhaustive]`, so the //! mapping stays total. use promptforge_model_client::Error as GatewayClientError; use promptforge_model_client::model::ModelId; -use promptforge_tools::ToolId; +use shared_promptforge_api::tools::ToolId; /// A type-erased owned error cause used by the internal substrate. pub(crate) type BoxedSource = Box; @@ -24,7 +24,7 @@ pub(crate) type BoxedSource = Box; /// [`SharedSource`] lets the typed cause be retained as a `#[source]` and cloned /// cheaply per lookup instead of being flattened to a string (resolve F4). /// -/// `promptforge-core`'s substrate carries this same type in its +/// `promptforge-api`'s substrate carries this same type in its /// `BindQuery`/`ModelBindQuery` variants, so the cross-crate mapping needs no /// re-wrapping. #[derive(Debug, Clone)] @@ -56,7 +56,7 @@ impl std::error::Error for SharedSource { /// bridging, capability binding, and Lua compile/runtime failures. /// /// `#[doc(hidden)]`: this type exists in the public item tree only so the -/// companion `promptforge-core` crate can convert it back onto its own +/// companion `promptforge-api` crate can convert it back onto its own /// substrate variant-for-variant. It is not host API. #[derive(Debug, thiserror::Error)] #[doc(hidden)] @@ -334,7 +334,7 @@ impl Error { /// Wrap a tool failure as [`Error::Tool`], preserving the tool's own /// error as the `#[source]` cause rather than discarding it. - pub(crate) fn tool(source: promptforge_tools::ToolError) -> Error { + pub(crate) fn tool(source: shared_promptforge_api::tools::ToolError) -> Error { Error::Tool { message: source.to_string(), source: Box::new(source), @@ -346,7 +346,7 @@ impl Error { /// variants map variant-for-variant (they are the only ones a /// `models.bind`/`models.default` resolution can produce), and /// `ModelSetLock` flattens to [`Error::Lua`], matching the mapping -/// `promptforge-core` has always applied. Any remaining transport variant is +/// `promptforge-api` has always applied. Any remaining transport variant is /// unreachable on the model-resolution path and degrades to its display /// string rather than fabricating a classification. impl From for Error { diff --git a/crates/promptforge-lua/src/handles.rs b/crates/promptforge-lua/src/handles.rs index f3b425bbf..6992a7d5e 100644 --- a/crates/promptforge-lua/src/handles.rs +++ b/crates/promptforge-lua/src/handles.rs @@ -145,7 +145,7 @@ impl ToolBinding { /// Builds a binding for a test double: the identity comes from the tool, /// with no override and no recorded clashes. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core`'s executor + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s executor /// tests, not host API. #[doc(hidden)] #[must_use] @@ -285,7 +285,7 @@ pub struct ToolSet { impl ToolSet { /// Builds a set from owned parts, for executor test doubles. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core`'s executor + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s executor /// tests, not host API. #[doc(hidden)] #[must_use] diff --git a/crates/promptforge-lua/src/hardening.rs b/crates/promptforge-lua/src/hardening.rs index cfb65df5f..f0a5fcb93 100644 --- a/crates/promptforge-lua/src/hardening.rs +++ b/crates/promptforge-lua/src/hardening.rs @@ -103,7 +103,7 @@ fn budget_hook( // Cooperative cancellation: abort a long-running Lua block promptly // when the run's CancelHandle is signaled (mapped to // Error::Interrupted at the runtime-error boundary). - if promptforge_core_support::cancel::is_cancelled() { + if shared_promptforge_api::cancel::is_cancelled() { return Err(mlua::Error::RuntimeError( "lua execution cancelled".to_string(), )); diff --git a/crates/promptforge-lua/src/lib.rs b/crates/promptforge-lua/src/lib.rs index cd4cba162..e4f606681 100644 --- a/crates/promptforge-lua/src/lib.rs +++ b/crates/promptforge-lua/src/lib.rs @@ -24,7 +24,7 @@ //! `SectionVm::run_chunk` as [`Error::Lua`]. //! //! Most of this crate is a `#[doc(hidden)]` cross-crate seam for -//! `promptforge-core`'s executor, which drives the VM and the coroutine +//! `promptforge-api`'s executor, which drives the VM and the coroutine //! protocol; [`LuaProgram`] is the documented exception. // These imports are re-exported `pub(crate)` so the child modules can pull @@ -42,13 +42,13 @@ pub(crate) use mlua::{ }; pub(crate) use serde_json::Value as Json; -pub(crate) use promptforge_core_support::observe::{Observation, Observer, detail}; -pub(crate) use promptforge_core_support::untrusted::GuardNonce; pub(crate) use promptforge_model_client::model::{ ModelBinding, ModelResolver, ModelSet, ModelView, }; pub(crate) use promptforge_store::{Access, Store}; -pub(crate) use promptforge_tools::{Tool, ToolCatalog, ToolId}; +pub(crate) use shared_promptforge_api::observe::{Observation, Observer, detail}; +pub(crate) use shared_promptforge_api::tools::{Tool, ToolCatalog, ToolId}; +pub(crate) use shared_promptforge_api::untrusted::GuardNonce; pub(crate) use crate::compactors::install_compactors; pub(crate) use crate::error::Result; @@ -114,7 +114,7 @@ mod models; mod protocol; mod runtime_events; -// The executor-facing surface: every item `promptforge-core` names crosses +// The executor-facing surface: every item `promptforge-api` names crosses // here. These are `#[doc(hidden)]` cross-crate seams, not host API; // `LuaProgram` is the documented exception. #[doc(hidden)] diff --git a/crates/promptforge-lua/src/live.rs b/crates/promptforge-lua/src/live.rs index 45fc249c0..d0c78f55b 100644 --- a/crates/promptforge-lua/src/live.rs +++ b/crates/promptforge-lua/src/live.rs @@ -93,7 +93,7 @@ impl LiveBindingProducer { /// Production reads the shared sets through the run context's views; test /// doubles snapshot straight from the producer. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core`'s tests, + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s tests, /// not host API. /// /// # Errors diff --git a/crates/promptforge-lua/src/messages/tests.rs b/crates/promptforge-lua/src/messages/tests.rs index 88313499c..4cf98d1a1 100644 --- a/crates/promptforge-lua/src/messages/tests.rs +++ b/crates/promptforge-lua/src/messages/tests.rs @@ -1,7 +1,7 @@ use mlua::{Lua, LuaSerdeExt, Value}; -use promptforge_core_support::observe::NullObserver; -use promptforge_core_support::untrusted::GuardNonce; use serde_json::json; +use shared_promptforge_api::observe::NullObserver; +use shared_promptforge_api::untrusted::GuardNonce; use super::install_messages; use crate::protocol::{Answer, MessageRecord, Request, ToolCallRecord, YieldParse}; diff --git a/crates/promptforge-lua/src/models/tests.rs b/crates/promptforge-lua/src/models/tests.rs index 7bd7c30d3..e9e29c2ae 100644 --- a/crates/promptforge-lua/src/models/tests.rs +++ b/crates/promptforge-lua/src/models/tests.rs @@ -321,9 +321,9 @@ fn model_runtime_starts_with_no_selection() { /// Builds a section VM with the Agent-window raw-id opt-in as `raw_ids`, /// host values injected (which installs the H2 `models` table). fn h2_vm(raw_ids: bool) -> crate::SectionVm { - let observer = promptforge_core_support::observe::NullObserver::default(); + let observer = shared_promptforge_api::observe::NullObserver::default(); let mut vm = crate::SectionVm::new( - &promptforge_core_support::untrusted::GuardNonce::fresh(), + &shared_promptforge_api::untrusted::GuardNonce::fresh(), "raw-id-test", &observer, "S", diff --git a/crates/promptforge-lua/src/program.rs b/crates/promptforge-lua/src/program.rs index 3a3d79a42..d842c8a50 100644 --- a/crates/promptforge-lua/src/program.rs +++ b/crates/promptforge-lua/src/program.rs @@ -53,7 +53,7 @@ fn compile_chunk(source: &str, location: &str) -> std::result::Result, C /// ``` /// use std::num::NonZeroU32; /// -/// use promptforge_core_support::observe::NullObserver; +/// use shared_promptforge_api::observe::NullObserver; /// use promptforge_lua::LuaProgram; /// /// let program = LuaProgram::compile( @@ -102,7 +102,7 @@ impl LuaProgram { /// use std::num::NonZeroU32; /// /// use mlua::Lua; - /// use promptforge_core_support::observe::NullObserver; + /// use shared_promptforge_api::observe::NullObserver; /// use promptforge_lua::LuaProgram; /// /// let program = LuaProgram::compile( @@ -239,7 +239,7 @@ impl LuaProgram { pub fn map_runtime_error(&self, error: &mlua::Error) -> Error { // A block aborted by the cancellation hook surfaces as an interruption, // not a Lua authoring error. - if promptforge_core_support::cancel::is_cancelled() { + if shared_promptforge_api::cancel::is_cancelled() { return Error::Interrupted; } let raw = error.to_string(); diff --git a/crates/promptforge-lua/src/protocol.rs b/crates/promptforge-lua/src/protocol.rs index d8c032170..d2908b40c 100644 --- a/crates/promptforge-lua/src/protocol.rs +++ b/crates/promptforge-lua/src/protocol.rs @@ -13,8 +13,8 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; -use promptforge_core_support::events::{CallMetrics, ToolCallEvent}; use promptforge_model_client::model::ModelBinding; +use shared_promptforge_api::events::{CallMetrics, ToolCallEvent}; use crate::tools::tool_alias; use crate::{ @@ -1647,7 +1647,7 @@ mod tests { let handle = crate::LuaToolHandle::from_binding( "echo", "echo tool", - &promptforge_tools::ToolId::new("tests", "echo").expect("valid id"), + &shared_promptforge_api::tools::ToolId::new("tests", "echo").expect("valid id"), ); let userdata = lua.create_userdata(handle).expect("userdata"); table.raw_set("alias", userdata).expect("raw_set"); @@ -2133,7 +2133,7 @@ mod tests { #[test] fn an_ok_chat_reply_answer_resumes_as_a_table_with_nil_tool_calls() { - use promptforge_core_support::events::{ClientTiming, Usage}; + use shared_promptforge_api::events::{ClientTiming, Usage}; let lua = Lua::new(); let result = ChatResult { diff --git a/crates/promptforge-lua/src/runtime_events.rs b/crates/promptforge-lua/src/runtime_events.rs index 27d8e9d2f..d79b56096 100644 --- a/crates/promptforge-lua/src/runtime_events.rs +++ b/crates/promptforge-lua/src/runtime_events.rs @@ -14,7 +14,7 @@ //! Installed by the agent executor alone; a section VM never has a //! `runtime` global. -use promptforge_core_support::events::EventLog; +use shared_promptforge_api::events::EventLog; use super::{ Arc, AtomicU64, Error, Lua, LuaSerdeExt, MetaMethod, Ordering, Result, UserData, @@ -169,7 +169,7 @@ pub fn install_runtime_events( #[cfg(test)] mod tests { - use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; + use shared_promptforge_api::events::{RuntimeEvent, RuntimeEventKind}; use super::*; use crate::AtomicUsize; diff --git a/crates/promptforge-lua/src/tests.rs b/crates/promptforge-lua/src/tests.rs index deb20564d..fb0941bd6 100644 --- a/crates/promptforge-lua/src/tests.rs +++ b/crates/promptforge-lua/src/tests.rs @@ -3,10 +3,10 @@ use std::sync::{Arc, Mutex}; use super::*; use crate::program::map_chunk_line_to_absolute; use crate::vm::{LocalTools, LuaOutcome, run_chunk}; -use promptforge_core_support::observe::{NullObserver, Observation}; use promptforge_store::Store; -use promptforge_tools::{Tool, ToolError, ToolOutput}; use serde_json::json; +use shared_promptforge_api::observe::{NullObserver, Observation}; +use shared_promptforge_api::tools::{Tool, ToolError, ToolOutput}; use shared_vfs::{ExecId, Origin, Vfs, VfsAccess, VfsError, VfsPath, VfsRef}; const EXECUTION: &str = "lua-test"; @@ -1978,7 +1978,7 @@ stack traceback: #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn long_running_lua_block_cancels_cooperatively() { - use promptforge_core_support::cancel::{self, CancelHandle}; + use shared_promptforge_api::cancel::{self, CancelHandle}; use std::time::{Duration, Instant}; // An unbounded loop that, without cooperative cancellation, would run @@ -2330,7 +2330,7 @@ fn dangerous_globals_absent() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_pre_cancelled_run_aborts_a_tight_loop_promptly() { - use promptforge_core_support::cancel::{self, CancelHandle}; + use shared_promptforge_api::cancel::{self, CancelHandle}; use std::time::{Duration, Instant}; // No instruction ceiling aborts a runaway block anymore; the cancel flag, diff --git a/crates/promptforge-lua/src/tools/tests.rs b/crates/promptforge-lua/src/tools/tests.rs index e92d319fc..af0214531 100644 --- a/crates/promptforge-lua/src/tools/tests.rs +++ b/crates/promptforge-lua/src/tools/tests.rs @@ -1,7 +1,7 @@ use mlua::{Lua, Value, Variadic}; -use promptforge_core_support::observe::NullObserver; -use promptforge_core_support::untrusted::GuardNonce; use serde_json::json; +use shared_promptforge_api::observe::NullObserver; +use shared_promptforge_api::untrusted::GuardNonce; use super::decode::{add_local_params_schema, collect_tools_add_entries, tool_alias}; use super::userdata::LuaToolHandle; @@ -9,7 +9,7 @@ use super::{install_h2_tools, install_tool_call_counts}; use crate::handles::{LuaFanoutResult, ToolSet}; use crate::scope::ToolRuntime; use crate::{SectionVm, ToolBinding}; -use promptforge_tools::ToolId; +use shared_promptforge_api::tools::ToolId; use std::sync::{Arc, Mutex}; /// A fresh stock handle's access capability for a test VM. @@ -240,7 +240,7 @@ fn tool_call_counts_seed_read_and_reject_unknown_keys() { struct EchoTool; #[async_trait::async_trait] -impl promptforge_tools::Tool for EchoTool { +impl shared_promptforge_api::tools::Tool for EchoTool { fn id(&self) -> ToolId { ToolId::new("tests", "echo").expect("valid id") } @@ -268,7 +268,10 @@ impl promptforge_tools::Tool for EchoTool { async fn call( &self, _args: serde_json::Value, - ) -> std::result::Result { - Ok(promptforge_tools::ToolOutput::trusted("echoed")) + ) -> std::result::Result< + shared_promptforge_api::tools::ToolOutput, + shared_promptforge_api::tools::ToolError, + > { + Ok(shared_promptforge_api::tools::ToolOutput::trusted("echoed")) } } diff --git a/crates/promptforge-lua/src/tools/userdata.rs b/crates/promptforge-lua/src/tools/userdata.rs index 25f388e57..dc983ebce 100644 --- a/crates/promptforge-lua/src/tools/userdata.rs +++ b/crates/promptforge-lua/src/tools/userdata.rs @@ -10,8 +10,8 @@ //! callers that ignore the return value keep working. use mlua::{LuaSerdeExt, MetaMethod, UserData, UserDataFields, UserDataMethods, Value}; -use promptforge_tools::{Tool, ToolId}; use serde_json::{Value as Json, json}; +use shared_promptforge_api::tools::{Tool, ToolId}; /// Inspectable Tool object returned by Lua `tools.bind`. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/promptforge-lua/src/vm.rs b/crates/promptforge-lua/src/vm.rs index 1a534dbb7..df90e63cf 100644 --- a/crates/promptforge-lua/src/vm.rs +++ b/crates/promptforge-lua/src/vm.rs @@ -49,8 +49,8 @@ pub(crate) fn pack_sequence( /// # Examples /// ```text /// use promptforge_lua::SectionVm; -/// use promptforge_core_support::observe::NullObserver; -/// use promptforge_core_support::untrusted::GuardNonce; +/// use shared_promptforge_api::observe::NullObserver; +/// use shared_promptforge_api::untrusted::GuardNonce; /// /// let nonce = GuardNonce::fresh(); /// let vm = SectionVm::new(&nonce, "example-run", &NullObserver::default(), "Example")?; @@ -224,8 +224,8 @@ impl SectionVm { /// # Examples /// ```text /// use promptforge_lua::SectionVm; - /// use promptforge_core_support::observe::NullObserver; - /// use promptforge_core_support::untrusted::GuardNonce; + /// use shared_promptforge_api::observe::NullObserver; + /// use shared_promptforge_api::untrusted::GuardNonce; /// /// let nonce = GuardNonce::fresh(); /// let vm = SectionVm::new(&nonce, "example-run", &NullObserver::default(), "Example")?; @@ -405,8 +405,8 @@ impl SectionVm { /// # Examples /// ```text /// use promptforge_lua::SectionVm; - /// use promptforge_core_support::observe::NullObserver; - /// use promptforge_core_support::untrusted::GuardNonce; + /// use shared_promptforge_api::observe::NullObserver; + /// use shared_promptforge_api::untrusted::GuardNonce; /// /// let nonce = GuardNonce::fresh(); /// let vfs = promptforge_vfs::empty(); @@ -743,7 +743,7 @@ impl SectionVm { /// Returns [`Error::Lua`] if host values have not been injected, execution /// fails, or the program returns a non-scalar value. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core`'s executor + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s executor /// tests, not host API. #[doc(hidden)] pub fn run_chunk( @@ -781,8 +781,8 @@ impl SectionVm { /// # Examples /// ```text /// use promptforge_lua::SectionVm; - /// use promptforge_core_support::observe::NullObserver; - /// use promptforge_core_support::untrusted::GuardNonce; + /// use shared_promptforge_api::observe::NullObserver; + /// use shared_promptforge_api::untrusted::GuardNonce; /// /// let nonce = GuardNonce::fresh(); /// let vfs = promptforge_vfs::empty(); @@ -859,7 +859,7 @@ impl SectionVm { /// Returns frozen tool bindings and the live H2 addition runtime. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core`'s executor + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s executor /// tests, not host API. #[doc(hidden)] #[must_use] @@ -872,7 +872,7 @@ impl SectionVm { /// Test-only: production reads the run's shared set through the model /// view; tests snapshot straight from the VM. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core`'s tests, + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s tests, /// not host API. #[doc(hidden)] #[must_use] @@ -952,8 +952,8 @@ impl SectionVm { /// # Examples /// ```text /// use promptforge_lua::SectionVm; - /// use promptforge_core_support::observe::NullObserver; - /// use promptforge_core_support::untrusted::GuardNonce; + /// use shared_promptforge_api::observe::NullObserver; + /// use shared_promptforge_api::untrusted::GuardNonce; /// /// let nonce = GuardNonce::fresh(); /// let vm = SectionVm::new(&nonce, "example-run", &NullObserver::default(), "Example")?; diff --git a/crates/promptforge-model-client/AGENTS.md b/crates/promptforge-model-client/AGENTS.md index 75d6a8db8..c9d34031c 100644 --- a/crates/promptforge-model-client/AGENTS.md +++ b/crates/promptforge-model-client/AGENTS.md @@ -4,5 +4,5 @@ This crate owns the OpenAI-shaped Gateway model transport and model-binding voca - This is a Gateway model client, not a universal transport. Other protocols use separate clients. - The client does not depend on a parser, Lua runtime, store, observer, or executor. Executors adapt to it. -- Metrics vocabulary is canonical in `promptforge-core-support`. This crate parses responses into those types and never defines a parallel metrics model. +- Metrics vocabulary is canonical in `shared-promptforge-api`. This crate parses responses into those types and never defines a parallel metrics model. - Hidden cross-crate seams let executors reach non-host internals. They must not gain documented status without a design change. diff --git a/crates/promptforge-model-client/Cargo.toml b/crates/promptforge-model-client/Cargo.toml index dd7e3ff1a..4b23980ca 100644 --- a/crates/promptforge-model-client/Cargo.toml +++ b/crates/promptforge-model-client/Cargo.toml @@ -13,13 +13,10 @@ categories = ["web-programming::http-client"] documentation = "https://cppalliance.github.io/promptforge/" [dependencies] -futures-util.workspace = true # The canonical metrics vocabulary (Usage, LlamaTimings, VllmMetrics, # ClientTiming, CallMetrics) this crate parses response bodies into and # re-exports. -promptforge-core-support.workspace = true -# The serde feature decodes the gateway's progress event stream. -shared-progress = { workspace = true, features = ["serde"] } +shared-promptforge-api.workspace = true promptforge-tool-picker.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/crates/promptforge-model-client/README.md b/crates/promptforge-model-client/README.md index fb955d5d0..682fce5fc 100644 --- a/crates/promptforge-model-client/README.md +++ b/crates/promptforge-model-client/README.md @@ -14,8 +14,6 @@ the one completion method and always streams SSE internally: it requests `StreamDelta` text or reasoning fragment (a caller with no use for deltas passes a no-op closure). A tool-call batch finished by `length` or `content_filter` fails whole, so partial arguments never execute. -`subscribe_progress` consumes the gateway's `GET /admin/progress` SSE -stream as decoded `shared-progress` events. Each `Completion` carries the call's metadata parsed from the stream: the serving `model`, `usage` token accounting (with cached- and @@ -23,6 +21,6 @@ reasoning-token details), llama.cpp `timings`, vLLM `metrics`, and a `client_timing` (TTFT, mean inter-token latency, end-to-end) measured on the client's own clock. The metrics vocabulary (`Usage`, `LlamaTimings`, `VllmMetrics`, `ClientTiming`, `CallMetrics`) is canonical in -`promptforge-core-support` and re-exported at this crate's root. A +`shared-promptforge-api` and re-exported at this crate's root. A malformed metadata section degrades to `None` with a `tracing` warning; it never fails the call. diff --git a/crates/promptforge-model-client/src/client.rs b/crates/promptforge-model-client/src/client.rs index eb298f04e..d610caf39 100644 --- a/crates/promptforge-model-client/src/client.rs +++ b/crates/promptforge-model-client/src/client.rs @@ -19,12 +19,13 @@ mod transport; mod wire; pub use config::{GatewayEndpoint, SecretError, SecretString}; +// Canonical in `shared-promptforge-api`; re-exported so the +// `promptforge_model_client::client::StreamDelta` path keeps resolving. +pub use shared_promptforge_api::wire::StreamDelta; pub use transport::GatewayClient; #[doc(hidden)] pub use wire::ToolSchemaError; -pub use wire::{ - Completion, CompletionResult, Message, StreamDelta, ToolArguments, ToolCall, ToolSchema, -}; +pub use wire::{Completion, CompletionResult, Message, ToolArguments, ToolCall, ToolSchema}; #[cfg(test)] mod tests; diff --git a/crates/promptforge-model-client/src/client/stream.rs b/crates/promptforge-model-client/src/client/stream.rs index e1f709e38..20a9f5ed2 100644 --- a/crates/promptforge-model-client/src/client/stream.rs +++ b/crates/promptforge-model-client/src/client/stream.rs @@ -18,8 +18,8 @@ use std::collections::BTreeMap; use serde_json::{Map, Value}; +use super::StreamDelta; use super::transport::escape_controls; -use super::wire::StreamDelta; use crate::{Error, Result}; /// Splits a raw SSE byte stream into `data:` payloads. diff --git a/crates/promptforge-model-client/src/client/transport.rs b/crates/promptforge-model-client/src/client/transport.rs index f97beb158..38a2cfaa2 100644 --- a/crates/promptforge-model-client/src/client/transport.rs +++ b/crates/promptforge-model-client/src/client/transport.rs @@ -5,8 +5,8 @@ use std::fmt; use std::num::NonZeroU64; use std::time::{Duration, Instant}; -use promptforge_core_support::events::ClientTiming; use serde_json::Value; +use shared_promptforge_api::events::ClientTiming; use super::stream::{Applied, SseScanner, StreamAccumulator}; use super::{Completion, GatewayEndpoint, Message, SecretString, StreamDelta, ToolSchema}; diff --git a/crates/promptforge-model-client/src/client/wire.rs b/crates/promptforge-model-client/src/client/wire.rs index 2ab18ae8f..a390a0bfa 100644 --- a/crates/promptforge-model-client/src/client/wire.rs +++ b/crates/promptforge-model-client/src/client/wire.rs @@ -1,8 +1,8 @@ //! Wire types for the chat-completions protocol: messages, tool schemas, //! tool calls, and completion results. -use promptforge_core_support::events::{ClientTiming, LlamaTimings, Usage, VllmMetrics}; use serde_json::Value; +use shared_promptforge_api::events::{ClientTiming, LlamaTimings, Usage, VllmMetrics}; /// A single chat message. /// @@ -187,7 +187,7 @@ pub struct ToolSchema { /// `#[doc(hidden)]`: `ToolSchema` is built only inside the workspace (from the /// executor's `Tool` contract), so the raw-`Value` validation and its error /// stay out of the documented API (client F8, lib F3). The type is visible -/// only so the companion `promptforge-core` crate can box it as an error +/// only so the companion `promptforge-api` crate can box it as an error /// source. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[doc(hidden)] @@ -359,22 +359,6 @@ impl ToolArguments<'_> { } } -/// One live increment from a streaming completion. -/// -/// [`GatewayClient::complete`](crate::client::GatewayClient::complete) invokes -/// its delta callback with these as the stream arrives: answer text and the -/// reasoning side channel stay separated so a consumer can render them -/// differently. Tool-call fragments are never surfaced as deltas; they buffer -/// inside the client until the batch is complete and validated. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum StreamDelta { - /// A fragment of the assistant's answer text. - Text(String), - /// A fragment of the reasoning side channel, never part of the answer. - Reasoning(String), -} - /// The outcome of a completion round trip. /// /// `Eq` holds because [`ToolCall`] arguments are a [`serde_json::Value`], diff --git a/crates/promptforge-model-client/src/error.rs b/crates/promptforge-model-client/src/error.rs index 89c4a005b..fa8b75f00 100644 --- a/crates/promptforge-model-client/src/error.rs +++ b/crates/promptforge-model-client/src/error.rs @@ -1,11 +1,11 @@ //! The crate's internal error substrate. //! -//! [`Error`] mirrors the role `promptforge-core`'s substrate plays there: it is +//! [`Error`] mirrors the role `promptforge-api`'s substrate plays there: it is //! never part of the documented API. Every public boundary returns its own //! typed error ([`crate::model::CompletionError`], [`crate::client::SecretError`], //! [`crate::model::ModelIdError`]); those wrappers classify this substrate and //! preserve its source. The substrate is `#[doc(hidden)]` and re-exported only -//! so `promptforge-core` can map every variant back onto its own substrate +//! so `promptforge-api` can map every variant back onto its own substrate //! verbatim; it is not a stable API and is not marked `#[non_exhaustive]`, so //! that mapping stays total. @@ -46,7 +46,7 @@ impl std::error::Error for SharedSource { /// transport, and model-binding resolution failures. /// /// `#[doc(hidden)]`: this type exists in the public item tree only so the -/// companion `promptforge-core` crate can convert it back onto its own +/// companion `promptforge-api` crate can convert it back onto its own /// substrate variant-for-variant. It is not host API. #[derive(Debug, thiserror::Error)] #[doc(hidden)] @@ -199,7 +199,7 @@ pub enum Error { /// A lock on the shared model set was poisoned. /// /// `Display` is the bare message so the companion crate can reclassify the - /// failure (`promptforge-core` maps it onto its own Lua-layer variant) + /// failure (`promptforge-api` maps it onto its own Lua-layer variant) /// without a wording change. #[error("{0}")] ModelSetLock(String), diff --git a/crates/promptforge-model-client/src/lib.rs b/crates/promptforge-model-client/src/lib.rs index 87bc3960f..30551e0f2 100644 --- a/crates/promptforge-model-client/src/lib.rs +++ b/crates/promptforge-model-client/src/lib.rs @@ -13,9 +13,12 @@ //! //! The metrics vocabulary ([`Usage`], [`LlamaTimings`], [`VllmMetrics`], //! [`ClientTiming`], [`CallMetrics`]) is canonical in -//! `promptforge-core-support` and re-exported here: the client parses each +//! `shared-promptforge-api` and re-exported here: the client parses each //! response body's call metadata into it, and [`client::Completion`] carries -//! the result. +//! the result. The model identity/catalog vocabulary ([`model::ModelId`], +//! [`model::ModelCatalog`], [`model::ModelDescriptor`], +//! [`model::ThinkingMode`]) and the streaming [`client::StreamDelta`] are +//! canonical there too and re-exported through their historical paths. //! //! The crate contains no prompt parser, no Lua runtime, and no executor; it is //! the gateway's model client only, never a universal client. @@ -29,6 +32,6 @@ mod normalize; pub use crate::error::Error; pub(crate) use crate::error::Result; -pub use promptforge_core_support::events::{ +pub use shared_promptforge_api::events::{ CallMetrics, ClientTiming, LlamaTimings, Usage, VllmMetrics, }; diff --git a/crates/promptforge-model-client/src/model.rs b/crates/promptforge-model-client/src/model.rs index f64f5ef33..d990d2166 100644 --- a/crates/promptforge-model-client/src/model.rs +++ b/crates/promptforge-model-client/src/model.rs @@ -17,121 +17,44 @@ use serde_json::Value; use crate::Result; mod error; -mod ids; mod options; mod resolver; mod transport; pub use error::{CompletionError, CompletionErrorKind}; -pub use ids::{ModelCatalogError, ModelId, ModelIdError}; pub use options::{ - CompletionOptions, ModelBindOpts, ModelBinding, ModelDescriptor, ModelInvocation, ModelSet, - ModelView, Temperature, TemperatureError, ThinkingMode, + CompletionOptions, ModelBindOpts, ModelBinding, ModelInvocation, ModelSet, ModelView, + Temperature, TemperatureError, }; +// The model identity/catalog vocabulary is canonical in +// `shared-promptforge-api` and re-exported here so existing +// `promptforge_model_client::model::` paths keep resolving. pub use resolver::PickerModelResolver; -pub use transport::{fetch_model_catalog, subscribe_progress}; +pub use shared_promptforge_api::models::{ + ModelCatalog, ModelCatalogError, ModelDescriptor, ModelId, ModelIdError, ThinkingMode, +}; +pub use transport::fetch_model_catalog; -/// Complete live model set for one bind pass. +/// Returns the descriptors satisfying `opts` as borrowed references. /// -/// `#[non_exhaustive]` so the collision-free catalog invariant is only ever -/// established through [`ModelCatalog::new`]/[`ModelCatalog::empty`]. -// No `Eq`: bindings carry `f64` temperatures transitively. -#[derive(Debug, Clone, Default, PartialEq)] -#[non_exhaustive] -pub struct ModelCatalog { - models: Vec, -} - -impl ModelCatalog { - /// Builds a catalog from descriptors in host order. - /// - /// # Errors - /// Returns [`ModelCatalogError::DuplicateId`] when two descriptors share one - /// stable [`ModelId`], so an ambiguous catalog is unrepresentable. - /// - /// # Examples - /// - /// ``` - /// use std::num::NonZeroU32; - /// use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; - /// - /// let ctx = NonZeroU32::new(8_192).ok_or("context is non-zero")?; - /// let id = ModelId::gateway("small")?; - /// let catalog = ModelCatalog::new([ModelDescriptor::new( - /// id.clone(), - /// "A tiny model", - /// ctx, - /// ThinkingMode::Never, - /// )])?; - /// assert!(catalog.contains(&id)); - /// assert_eq!(catalog.models().len(), 1); - /// # Ok::<(), Box>(()) - /// ``` - pub fn new( - models: impl IntoIterator, - ) -> std::result::Result { - let models: Vec = models.into_iter().collect(); - for (index, model) in models.iter().enumerate() { - if models[..index].iter().any(|prior| prior.id() == model.id()) { - return Err(ModelCatalogError::DuplicateId { - server: model.id().server().to_owned(), - name: model.id().name().to_owned(), - }); - } - } - Ok(Self { models }) - } - - /// Builds a catalog from descriptors already known to be collision-free. - /// - /// Used by internal callers whose inputs are already validated, where - /// duplicate checking is redundant. - pub(crate) fn from_validated(models: Vec) -> ModelCatalog { - Self { models } - } - - /// An empty catalog; every `models.bind` resolves as absent. - #[must_use] - pub fn empty() -> Self { - Self::from_validated(Vec::new()) - } - - /// Returns every descriptor. - #[must_use] - pub fn models(&self) -> &[ModelDescriptor] { - &self.models - } - - /// Returns whether the catalog has no entries. - #[must_use] - pub fn is_empty(&self) -> bool { - self.models.is_empty() - } - - /// Looks up a descriptor by stable identity. - #[must_use] - pub fn get(&self, id: &ModelId) -> Option<&ModelDescriptor> { - self.models.iter().find(|model| model.id() == id) - } - - /// Returns whether the catalog contains a descriptor with `id`. - #[must_use] - pub fn contains(&self, id: &ModelId) -> bool { - self.get(id).is_some() - } - +/// This clones nothing (MODEL-017): the semantic resolver builds its picker +/// directly from these borrowed matches and selects the resolved descriptor +/// back out of the same borrowed slice. +/// +/// `#[doc(hidden)]`: a cross-crate seam for the resolver and its test +/// doubles in `promptforge-api`, not host API. An extension trait because +/// [`ModelCatalog`] is canonical in `shared-promptforge-api` while +/// [`ModelBindOpts`] binding machinery stays here. +#[doc(hidden)] +pub trait ModelCatalogFiltered { /// Returns the descriptors satisfying `opts` as borrowed references. - /// - /// This clones nothing (MODEL-017): the semantic resolver builds its picker - /// directly from these borrowed matches and selects the resolved descriptor - /// back out of the same borrowed slice. - /// - /// `#[doc(hidden)]`: a cross-crate seam for the resolver and its test - /// doubles in `promptforge-core`, not host API. - #[doc(hidden)] #[must_use] - pub fn filtered(&self, opts: &ModelBindOpts) -> Vec<&ModelDescriptor> { - self.models + fn filtered(&self, opts: &ModelBindOpts) -> Vec<&ModelDescriptor>; +} + +impl ModelCatalogFiltered for ModelCatalog { + fn filtered(&self, opts: &ModelBindOpts) -> Vec<&ModelDescriptor> { + self.models() .iter() .filter(|model| satisfies_constraints(model, opts)) .collect() diff --git a/crates/promptforge-model-client/src/model/ids.rs b/crates/promptforge-model-client/src/model/ids.rs deleted file mode 100644 index e2daef3eb..000000000 --- a/crates/promptforge-model-client/src/model/ids.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Stable model identity and the catalog/identity validation errors. - -/// Stable identity of one catalogued model. -/// -/// v0 uses the `"gateway"` namespace plus the caller-facing model name (the -/// gateway `[[model]].name` / OpenAI `id`). -/// -/// `#[non_exhaustive]` so the invariant-bearing identity is only ever built -/// through [`ModelId::new`]/[`ModelId::gateway`], never by a struct literal. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[non_exhaustive] -pub struct ModelId { - server: String, - name: String, -} - -impl ModelId { - /// The v0 gateway identity namespace. - pub const GATEWAY: &'static str = "gateway"; - - /// Builds an identity from its server namespace and model name. - /// - /// # Errors - /// Returns [`ModelIdError`] if `server` or `name` is empty or contains a - /// control character, so an unusable identity is unrepresentable. - /// - /// # Examples - /// - /// ``` - /// use promptforge_model_client::model::ModelId; - /// - /// let id = ModelId::new(ModelId::GATEWAY, "claude-sonnet-4-6")?; - /// assert_eq!(id.server(), "gateway"); - /// assert_eq!(id.name(), "claude-sonnet-4-6"); - /// # Ok::<(), promptforge_model_client::model::ModelIdError>(()) - /// ``` - pub fn new( - server: impl Into, - name: impl Into, - ) -> std::result::Result { - let server = server.into(); - let name = name.into(); - Self::validate("server", &server)?; - Self::validate("name", &name)?; - Ok(Self { server, name }) - } - - /// Builds a gateway-namespaced identity from a caller-facing model name. - /// - /// # Errors - /// Returns [`ModelIdError`] if `name` is empty or contains a control - /// character. - pub fn gateway(name: impl Into) -> std::result::Result { - Self::new(Self::GATEWAY, name) - } - - /// Builds an identity from components already known to be valid. - /// - /// `#[doc(hidden)]`: a cross-crate seam for workspace-internal callers - /// reconstructing an identity from an existing [`ModelId`]'s parts, where - /// [`ModelId::new`]'s validation is redundant. Not host API. - #[doc(hidden)] - pub fn from_validated(server: impl Into, name: impl Into) -> ModelId { - ModelId { - server: server.into(), - name: name.into(), - } - } - - /// The `RS` (U+001E) record separator the model picker uses to delimit - /// encoded identities. Accepting it inside a component would let an id - /// collide or corrupt that encoding, so it is rejected explicitly. - pub(crate) const PICKER_SEPARATOR: char = '\u{001e}'; - - /// Validates one identity component, naming the field in any error. - /// - /// Rejection is by Unicode scalar, not raw byte (MODEL-004): every control - /// character is refused, including C1 controls such as U+0085 (NEL) whose - /// UTF-8 encoding a byte-range scan would miss, and the picker separator - /// U+001E in particular. - fn validate(field: &'static str, value: &str) -> std::result::Result<(), ModelIdError> { - if value.is_empty() { - return Err(ModelIdError { - field, - reason: "must not be empty", - }); - } - if value - .chars() - .any(|c| c.is_control() || c == Self::PICKER_SEPARATOR) - { - return Err(ModelIdError { - field, - reason: "must not contain a control character", - }); - } - Ok(()) - } - - /// Returns the identity namespace. - #[must_use] - pub fn server(&self) -> &str { - &self.server - } - - /// Returns the caller-facing model name. - #[must_use] - pub fn name(&self) -> &str { - &self.name - } -} - -/// The reason a [`ModelId`] could not be built from its components. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("invalid model id: {field} {reason}")] -#[non_exhaustive] -pub struct ModelIdError { - /// Which component was rejected (`server` or `name`). - field: &'static str, - /// Why it was rejected. - reason: &'static str, -} - -/// The reason a [`crate::model::ModelCatalog`] could not be built from its -/// descriptors. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum ModelCatalogError { - /// Two descriptors shared one stable [`ModelId`], which would make lookups - /// ambiguous. - #[error("duplicate model identity in catalog: {server}/{name}")] - #[non_exhaustive] - DuplicateId { - /// The repeated identity's server namespace. - server: String, - /// The repeated identity's model name. - name: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_c0_c1_and_picker_separator_controls() { - // The picker record separator (U+001E) must never survive into an id. - assert!(ModelId::new(ModelId::GATEWAY, "a\u{001e}b").is_err()); - // A C1 control (NEL, U+0085) whose UTF-8 bytes (0xC2 0x85) a byte-range - // scan would miss but a scalar `is_control` scan rejects (MODEL-004). - assert!(ModelId::new(ModelId::GATEWAY, "a\u{0085}b").is_err()); - // DEL (U+007F) and NUL are refused too. - assert!(ModelId::new(ModelId::GATEWAY, "a\u{007f}b").is_err()); - assert!(ModelId::new("srv\u{0000}", "name").is_err()); - // A benign multi-byte non-ASCII name is still accepted. - assert!(ModelId::new(ModelId::GATEWAY, "café-模型").is_ok()); - } -} diff --git a/crates/promptforge-model-client/src/model/options.rs b/crates/promptforge-model-client/src/model/options.rs index aef22e690..0a11fe918 100644 --- a/crates/promptforge-model-client/src/model/options.rs +++ b/crates/promptforge-model-client/src/model/options.rs @@ -1,12 +1,11 @@ -//! Model value types: validated temperature, thinking mode, descriptor, -//! bind/invocation options, prompt-local bindings, and completion options. +//! Model value types: validated temperature, bind/invocation options, +//! prompt-local bindings, and completion options. use std::num::NonZeroU32; use std::sync::Mutex; -use serde::Deserialize; +use shared_promptforge_api::models::ModelId; -use super::ModelId; use crate::{Error, Result}; /// The largest sampling temperature the backend accepts. @@ -67,106 +66,6 @@ pub enum TemperatureError { }, } -/// Whether a catalogued model can emit thinking tokens. -/// -/// # Examples -/// -/// ``` -/// use promptforge_model_client::model::ThinkingMode; -/// -/// // Deserialized from the lowercase gateway wire form. -/// let mode: ThinkingMode = serde_json::from_str("\"switchable\"")?; -/// assert_eq!(mode, ThinkingMode::Switchable); -/// # Ok::<(), serde_json::Error>(()) -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -#[non_exhaustive] -pub enum ThinkingMode { - /// The backend never emits thinking tokens. - Never, - /// The backend always emits thinking tokens. - Always, - /// The client may turn thinking on or off per request. - Switchable, -} - -/// One catalogued model with live-resolution metadata. -/// -/// `#[non_exhaustive]` so the descriptor is only ever built through -/// [`ModelDescriptor::new`] and its validated context window is preserved. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct ModelDescriptor { - id: ModelId, - description: String, - context: NonZeroU32, - thinking: ThinkingMode, -} - -impl ModelDescriptor { - /// Builds a descriptor from its identity and catalog fields. - /// - /// The context window is a [`NonZeroU32`], so a zero-token window is - /// unrepresentable. - /// - /// # Examples - /// - /// ``` - /// use std::num::NonZeroU32; - /// use promptforge_model_client::model::{ModelDescriptor, ModelId, ThinkingMode}; - /// - /// let context = NonZeroU32::new(131_072).ok_or("context is non-zero")?; - /// let model = ModelDescriptor::new( - /// ModelId::gateway("analyst")?, - /// "A careful analysis model", - /// context, - /// ThinkingMode::Switchable, - /// ); - /// assert_eq!(model.context(), context); - /// assert_eq!(model.thinking(), ThinkingMode::Switchable); - /// # Ok::<(), Box>(()) - /// ``` - #[must_use] - pub fn new( - id: ModelId, - description: impl Into, - context: NonZeroU32, - thinking: ThinkingMode, - ) -> Self { - Self { - id, - description: description.into(), - context, - thinking, - } - } - - /// Returns the stable identity. - #[must_use] - pub fn id(&self) -> &ModelId { - &self.id - } - - /// Returns the prose used for semantic resolve. - #[must_use] - pub fn description(&self) -> &str { - &self.description - } - - /// Returns the context window size in tokens (always non-zero). - #[must_use] - pub fn context(&self) -> NonZeroU32 { - self.context - } - - /// Returns the thinking capability. - #[must_use] - pub fn thinking(&self) -> ThinkingMode { - self.thinking - } -} - /// Optional hard constraints and invocation parameters from `models.bind`. /// /// `context` and `thinking` filter the catalog. `temperature`, `max_tokens`, diff --git a/crates/promptforge-model-client/src/model/resolver.rs b/crates/promptforge-model-client/src/model/resolver.rs index de897e2a3..928163221 100644 --- a/crates/promptforge-model-client/src/model/resolver.rs +++ b/crates/promptforge-model-client/src/model/resolver.rs @@ -3,8 +3,8 @@ use promptforge_tool_picker::{CandidateGroup, ToolPicker}; use super::{ - ModelBindOpts, ModelCatalog, ModelInvocation, ModelResolver, ResolvedModel, - model_from_picker_id, picker_catalog_from, + ModelBindOpts, ModelCatalog, ModelCatalogFiltered, ModelInvocation, ModelResolver, + ResolvedModel, model_from_picker_id, picker_catalog_from, }; use crate::{Error, Result}; diff --git a/crates/promptforge-model-client/src/model/tests.rs b/crates/promptforge-model-client/src/model/tests.rs index e0ebaeea3..46666ced8 100644 --- a/crates/promptforge-model-client/src/model/tests.rs +++ b/crates/promptforge-model-client/src/model/tests.rs @@ -94,25 +94,6 @@ fn same_weights_different_invocation_compare_unequal() { assert_ne!(a.invocation(), b.invocation()); } -#[test] -fn model_id_rejects_empty_and_control_characters() { - assert!(ModelId::gateway("").is_err()); - assert!(ModelId::new("", "name").is_err()); - assert!(ModelId::new("server", "").is_err()); - assert!(ModelId::new("server", "na\nme").is_err()); - assert!(ModelId::gateway("valid-alias").is_ok()); -} - -#[test] -fn model_catalog_rejects_duplicate_ids() { - let descriptor = - |name: &str| ModelDescriptor::new(gateway_id(name), "d", ctx(8_192), ThinkingMode::Never); - let err = ModelCatalog::new([descriptor("dup"), descriptor("dup")]) - .expect_err("a catalog with duplicate ids must be rejected"); - assert!(matches!(err, ModelCatalogError::DuplicateId { .. })); - assert!(ModelCatalog::new([descriptor("a"), descriptor("b")]).is_ok()); -} - #[test] fn binding_construction_is_atomic_with_context() { let binding = ModelBinding::new( diff --git a/crates/promptforge-model-client/src/model/transport.rs b/crates/promptforge-model-client/src/model/transport.rs index a83c4c32a..a0a8055da 100644 --- a/crates/promptforge-model-client/src/model/transport.rs +++ b/crates/promptforge-model-client/src/model/transport.rs @@ -1,11 +1,8 @@ -//! Catalog transport: fetching and decoding gateway `GET /v1/models`, plus -//! the `GET /admin/progress` event-stream subscription. +//! Catalog transport: fetching and decoding gateway `GET /v1/models`. use std::num::NonZeroU32; -use futures_util::Stream; use serde::Deserialize; -use shared_progress::ProgressEvent; use super::{CompletionError, ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; use crate::Error; @@ -216,161 +213,6 @@ pub async fn fetch_model_catalog( }) } -/// The largest single SSE event block buffered before the stream refuses it, -/// in bytes. A peer that never sends a blank-line terminator would otherwise -/// grow the reassembly buffer unbounded (MODEL-010); sized well above any -/// realistic progress event. -const MAX_EVENT_BLOCK: usize = 1024 * 1024; - -/// One decoded item of a [`subscribe_progress`] stream. -type ProgressStreamItem = std::result::Result; - -/// Subscribes to a bearer-authed gateway `GET /admin/progress` event stream. -/// -/// `base_url` is the gateway root (for example `http://127.0.0.1:8081`), not -/// the OpenAI-shaped `/v1` API root [`fetch_model_catalog`] takes. -/// -/// The returned stream yields every [`ProgressEvent`] the gateway sends, -/// beginning with the snapshot replay of the operations live at connect time. -/// Heartbeat comment lines and other non-`data:` lines are skipped. -/// Intermediate events are lossy at the source, so the stream promises no -/// completeness; detect completion only from `Finished` events, never from a -/// fraction reaching 1.0. A `data:` line that does not decode is yielded as -/// one `Err` item without ending the stream; a read failure or an event block -/// oversized beyond one MiB is yielded as one `Err` item that ends the -/// stream. The stream ends when the gateway closes the body; whether to -/// resubscribe is the caller's decision. -/// -/// # Errors -/// Returns a [`CompletionError`] whose [`kind`](CompletionError::kind) is -/// `Transport` on transport failure and `Backend` on a non-success status -/// (for example 401 on a rejected token). Decode failures surface as per-item -/// `Err` values instead. -/// -/// # Examples -/// -/// ```no_run -/// # async fn run() -> Result<(), promptforge_model_client::model::CompletionError> { -/// use futures_util::StreamExt; -/// use promptforge_model_client::model::subscribe_progress; -/// -/// let events = subscribe_progress("http://127.0.0.1:8081", "secret-token").await?; -/// futures_util::pin_mut!(events); -/// while let Some(item) = events.next().await { -/// let event = item?; -/// println!("{}: {}", event.path, event.label); -/// } -/// # Ok(()) -/// # } -/// ``` -pub async fn subscribe_progress( - base_url: &str, - token: &str, -) -> std::result::Result + Send, CompletionError> { - let base = base_url.trim_end_matches('/'); - let response = get_authed(format!("{base}/admin/progress"), token).await?; - Ok(progress_event_stream(response)) -} - -/// Decodes an SSE body into an event stream: chunks are buffered until a -/// blank line terminates an event block (LF or CRLF line endings alike), -/// comment-only blocks (heartbeats) are skipped, and an undecodable block -/// becomes one `Err` item rather than killing the stream. A mid-stream read -/// failure likewise surfaces as one `Err` item, after which the stream ends. -/// A block that grows past -/// [`MAX_EVENT_BLOCK`] without a terminator is refused as one `Err` item, -/// after which the stream ends, so a peer cannot buffer the client unbounded. -fn progress_event_stream( - response: reqwest::Response, -) -> impl Stream + Send { - futures_util::stream::unfold( - (response, Vec::new(), false), - |(mut response, mut buffer, mut failed)| async move { - loop { - if let Some(item) = next_buffered_event(&mut buffer) { - return Some((item, (response, buffer, failed))); - } - if failed { - return None; - } - match response.chunk().await { - Ok(Some(chunk)) => { - if buffer.len() + chunk.len() > MAX_EVENT_BLOCK { - failed = true; - let item = - Err(CompletionError::from(Error::MalformedResponse(format!( - "progress event block exceeds the {MAX_EVENT_BLOCK}-byte limit" - )))); - return Some((item, (response, buffer, failed))); - } - buffer.extend_from_slice(&chunk); - } - // An incomplete trailing block is discarded, matching the - // SSE rule that only blank-line-terminated blocks dispatch. - Ok(None) => return None, - Err(source) => { - failed = true; - let item = Err(CompletionError::from(Error::http(source))); - return Some((item, (response, buffer, failed))); - } - } - } - }, - ) -} - -/// Pops the next decodable event out of `buffer`, or `None` when no complete -/// block is buffered yet. Comment-only blocks (heartbeats) are consumed and -/// skipped. -fn next_buffered_event(buffer: &mut Vec) -> Option { - loop { - let end = block_end(buffer)?; - let block: Vec = buffer.drain(..end).collect(); - if let Some(item) = parse_event_block(&block) { - return Some(item); - } - } -} - -/// The end (terminator included) of the first complete event block: a blank -/// line, whether the peer terminates its lines with LF or CRLF. -fn block_end(buffer: &[u8]) -> Option { - let lf = buffer - .windows(2) - .position(|pair| pair == b"\n\n") - .map(|at| at + 2); - let crlf = buffer - .windows(4) - .position(|quad| quad == b"\r\n\r\n") - .map(|at| at + 4); - [lf, crlf].into_iter().flatten().min() -} - -/// Decodes one SSE event block: `data:` lines join into the payload, comment -/// lines and unrecognized fields are ignored, and a block with no payload -/// (a heartbeat) yields `None`. -fn parse_event_block(block: &[u8]) -> Option { - let mut data: Vec = Vec::new(); - for line in block.split(|byte| *byte == b'\n') { - let line = line.strip_suffix(b"\r").unwrap_or(line); - if let Some(rest) = line.strip_prefix(b"data:") { - if !data.is_empty() { - data.push(b'\n'); - } - data.extend_from_slice(rest.strip_prefix(b" ").unwrap_or(rest)); - } - } - if data.is_empty() { - return None; - } - Some(serde_json::from_slice(&data).map_err(|source| { - CompletionError::from(Error::MalformedResponseSource { - message: "progress event was not valid JSON".to_owned(), - source: Box::new(source), - }) - })) -} - #[cfg(test)] mod tests { use super::*; @@ -496,270 +338,4 @@ mod tests { "the preserved source must be the reqwest read error, got {source}" ); } - - use shared_progress::EventState; - - /// Serializes a wire-format progress event by hand, so the tests pin the - /// JSON shape rather than the progress crate's constructors. - fn event_json(state: &serde_json::Value) -> String { - serde_json::json!({ - "operation": 7, - "path": "local-models/ggml/download", - "label": "Download", - "state": state, - }) - .to_string() - } - - /// A mock `GET /admin/progress` that requires the bearer token and - /// answers with `body` as the verbatim SSE payload. - fn mock_progress(body: String) -> axum::Router { - use axum::Router; - use axum::response::IntoResponse; - use axum::routing::get; - - Router::new().route( - "/admin/progress", - get(move |headers: axum::http::HeaderMap| { - let body = body.clone(); - async move { - let auth = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()); - if auth != Some("Bearer tok") { - return (axum::http::StatusCode::UNAUTHORIZED, "bad token").into_response(); - } - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - body, - ) - .into_response() - } - }), - ) - } - - #[tokio::test] - async fn subscribe_progress_decodes_events_and_skips_heartbeat_comments() { - use futures_util::StreamExt; - - let begun = event_json(&serde_json::json!({"Begun": {"weight": 2.0}})); - let updated = event_json(&serde_json::json!({"Updated": {"fraction": 0.5}})); - let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); - let body = format!( - ": heartbeat\n\ndata: {begun}\n\ndata: {updated}\n\n: heartbeat\n\ndata: {finished}\n\n" - ); - let addr = spawn_models(mock_progress(body)).await; - - let events: Vec<_> = subscribe_progress(&format!("http://{addr}"), "tok") - .await - .expect("a well-formed stream subscribes") - .collect() - .await; - - let states: Vec = events - .iter() - .map(|item| item.as_ref().expect("every item decodes").state) - .collect(); - assert_eq!( - states, - vec![ - EventState::Begun { weight: 2.0 }, - EventState::Updated { fraction: 0.5 }, - EventState::Finished { ok: true }, - ] - ); - } - - #[tokio::test] - async fn subscribe_progress_classifies_a_non_success_status() { - let addr = spawn_models(mock_progress(String::new())).await; - - let Err(err) = subscribe_progress(&format!("http://{addr}"), "wrong-token").await else { - panic!("a 401 response must surface as an error"); - }; - assert_eq!(err.kind(), CompletionErrorKind::Backend); - assert_eq!(err.status(), Some(401)); - } - - #[tokio::test] - async fn subscribe_progress_yields_one_error_per_bad_event_and_continues() { - use futures_util::StreamExt; - - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); - let body = format!("data: {begun}\n\ndata: {{not json\n\ndata: {finished}\n\n"); - let addr = spawn_models(mock_progress(body)).await; - - let events: Vec<_> = subscribe_progress(&format!("http://{addr}"), "tok") - .await - .expect("a well-formed stream subscribes") - .collect() - .await; - - assert_eq!(events.len(), 3, "one item per data block"); - assert!(events[0].is_ok(), "the leading event decodes"); - let err = events[1] - .as_ref() - .expect_err("the undecodable line is one error item"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - assert!( - events[2].is_ok(), - "a bad line must not end the stream: the trailing event still arrives" - ); - } - - #[tokio::test] - async fn subscribe_progress_reassembles_an_event_split_across_chunks() { - use futures_util::StreamExt; - - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let wire = format!("data: {begun}\n\n"); - let (head, tail) = wire.split_at(wire.len() / 2); - let (head, tail) = (head.to_owned(), tail.to_owned()); - let app = axum::Router::new().route( - "/admin/progress", - axum::routing::get(move || { - let chunks = vec![ - Ok::<_, std::convert::Infallible>(head.clone()), - Ok::<_, std::convert::Infallible>(tail.clone()), - ]; - async move { - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - axum::body::Body::from_stream(futures_util::stream::iter(chunks)), - ) - } - }), - ); - let addr = spawn_models(app).await; - - let events: Vec<_> = subscribe_progress(&format!("http://{addr}"), "tok") - .await - .expect("a well-formed stream subscribes") - .collect() - .await; - - assert_eq!(events.len(), 1, "the split block decodes as one event"); - assert!(events[0].is_ok(), "the reassembled event decodes"); - } - - #[tokio::test] - async fn subscribe_progress_yields_one_error_on_a_mid_stream_read_failure_then_ends() { - use futures_util::StreamExt; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // The server promises a large body, delivers one complete event, then - // drops the connection: the read failure must surface as one `Err` - // item that ends the stream, not as a hang or a silent close. - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - if let Ok((mut sock, _)) = listener.accept().await { - let mut buf = [0u8; 1024]; - let _ = sock.read(&mut buf).await; - let header = "HTTP/1.1 200 OK\r\n\ - Content-Type: text/event-stream\r\n\ - Content-Length: 1000000\r\n\r\n"; - let _ = sock.write_all(header.as_bytes()).await; - let _ = sock - .write_all(format!("data: {begun}\n\n").as_bytes()) - .await; - // Socket drops here: the promised body never completes. - } - }); - - let events: Vec<_> = subscribe_progress(&format!("http://{addr}"), "tok") - .await - .expect("a well-formed stream subscribes") - .collect() - .await; - - assert_eq!(events.len(), 2, "the decoded event, then one error item"); - assert!(events[0].is_ok(), "the leading event decodes"); - let err = events[1] - .as_ref() - .expect_err("the truncated body is one error item"); - assert_eq!(err.kind(), CompletionErrorKind::Transport); - } - - #[tokio::test] - async fn subscribe_progress_bounds_an_event_block_that_never_terminates() { - use futures_util::StreamExt; - - let body = "x".repeat(MAX_EVENT_BLOCK + 1); - let addr = spawn_models(mock_progress(body)).await; - - let events: Vec<_> = subscribe_progress(&format!("http://{addr}"), "tok") - .await - .expect("a well-formed stream subscribes") - .collect() - .await; - - assert_eq!(events.len(), 1, "the oversized block is one error item"); - let err = events[0] - .as_ref() - .expect_err("an unterminated oversized block must be refused"); - assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); - assert!( - err.to_string().contains("exceeds"), - "the bound must report the size limit, got {err}" - ); - } - - #[tokio::test] - async fn subscribe_progress_decodes_crlf_terminated_blocks() { - use futures_util::StreamExt; - - // A peer that terminates its lines with CRLF still dispatches: the - // blank-line terminator is `\r\n\r\n`, which contains no `\n\n`. - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); - let body = format!("data: {begun}\r\n\r\ndata: {finished}\r\n\r\n"); - let addr = spawn_models(mock_progress(body)).await; - - let events: Vec<_> = subscribe_progress(&format!("http://{addr}"), "tok") - .await - .expect("a well-formed stream subscribes") - .collect() - .await; - - let states: Vec = events - .iter() - .map(|item| item.as_ref().expect("every item decodes").state) - .collect(); - assert_eq!( - states, - vec![ - EventState::Begun { weight: 1.0 }, - EventState::Finished { ok: true }, - ] - ); - } - - #[tokio::test] - async fn subscribe_progress_discards_an_incomplete_trailing_block() { - use futures_util::StreamExt; - - // The body ends mid-block: only blank-line-terminated blocks - // dispatch, so the partial event is dropped and the stream ends. - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); - let body = format!("data: {begun}\n\ndata: {finished}"); - let addr = spawn_models(mock_progress(body)).await; - - let events: Vec<_> = subscribe_progress(&format!("http://{addr}"), "tok") - .await - .expect("a well-formed stream subscribes") - .collect() - .await; - - assert_eq!( - events.len(), - 1, - "the unterminated trailing block is discarded" - ); - assert!(events[0].is_ok(), "the complete event decodes"); - } } diff --git a/crates/promptforge-model-client/src/normalize.rs b/crates/promptforge-model-client/src/normalize.rs index 7e30d003c..19ac02e3b 100644 --- a/crates/promptforge-model-client/src/normalize.rs +++ b/crates/promptforge-model-client/src/normalize.rs @@ -13,12 +13,12 @@ //! Beside the strict turn parse, [`response_metadata`] leniently parses the //! body's call metadata - the serving `model`, `usage` token accounting, //! llama.cpp's `timings` extension, and vLLM's `metrics` extension - into the -//! canonical `promptforge-core-support` vocabulary. Metadata never fails a +//! canonical `shared-promptforge-api` vocabulary. Metadata never fails a //! completion: a malformed section degrades to `None` with a warning. -use promptforge_core_support::events::{LlamaTimings, Usage, VllmMetrics}; use serde::Deserialize; use serde_json::Value; +use shared_promptforge_api::events::{LlamaTimings, Usage, VllmMetrics}; use crate::client::{CompletionResult, ToolCall}; use crate::{Error, Result}; diff --git a/crates/promptforge-parser/Cargo.toml b/crates/promptforge-parser/Cargo.toml index 4d9338de3..18f9dea00 100644 --- a/crates/promptforge-parser/Cargo.toml +++ b/crates/promptforge-parser/Cargo.toml @@ -13,7 +13,7 @@ categories = ["text-processing", "parsing"] documentation = "https://cppalliance.github.io/promptforge/" [dependencies] -promptforge-core-support.workspace = true +shared-promptforge-api.workspace = true promptforge-lua.workspace = true pulldown-cmark.workspace = true serde.workspace = true diff --git a/crates/promptforge-parser/src/build.rs b/crates/promptforge-parser/src/build.rs index a5257891d..852d481e2 100644 --- a/crates/promptforge-parser/src/build.rs +++ b/crates/promptforge-parser/src/build.rs @@ -9,7 +9,7 @@ use std::ops::Range; use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; -use promptforge_core_support::observe::Observer; +use shared_promptforge_api::observe::Observer; use super::fence::{RawBlock, lua_block_location, split_section_blocks}; use super::list::{is_all_list_markers, parse_bullet_items}; diff --git a/crates/promptforge-parser/src/fence.rs b/crates/promptforge-parser/src/fence.rs index 8715fee54..2278391c2 100644 --- a/crates/promptforge-parser/src/fence.rs +++ b/crates/promptforge-parser/src/fence.rs @@ -10,7 +10,7 @@ use std::ops::Range; use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag}; -use promptforge_core_support::observe::Observer; +use shared_promptforge_api::observe::Observer; use super::build::{line_add, newlines_before, nz_source_line}; use super::{Block, LuaProgram, ParseErrorKind}; diff --git a/crates/promptforge-parser/src/lib.rs b/crates/promptforge-parser/src/lib.rs index aaa93442e..a9b22fdc9 100644 --- a/crates/promptforge-parser/src/lib.rs +++ b/crates/promptforge-parser/src/lib.rs @@ -16,7 +16,7 @@ //! //! The parser does no execution. It turns bytes into a [`Prompt`] tree. -use promptforge_core_support::observe::{Observer, detail}; +use shared_promptforge_api::observe::{Observer, detail}; pub use promptforge_lua::LuaProgram; @@ -40,7 +40,7 @@ pub(crate) type BoxedSource = Box; /// the public boundary. /// /// `#[doc(hidden)]`: this type exists in the public item tree only so the -/// companion `promptforge-core` crate can convert it back onto its own +/// companion `promptforge-api` crate can convert it back onto its own /// substrate variant-for-variant. It is not host API. #[derive(Debug, thiserror::Error)] #[doc(hidden)] @@ -159,7 +159,7 @@ impl ParseError { /// Unwraps the internal substrate error. /// - /// `#[doc(hidden)]`: cross-crate seam for `promptforge-core`'s own error + /// `#[doc(hidden)]`: cross-crate seam for `promptforge-api`'s own error /// substrate, mirroring the `promptforge-lua` precedent. Not host API. #[doc(hidden)] #[must_use] @@ -371,7 +371,7 @@ impl Prompt { /// `execution` identifier unchanged. /// /// ``` - /// use promptforge_core_support::observe::NullObserver; + /// use shared_promptforge_api::observe::NullObserver; /// use promptforge_parser::{Prompt, ParseErrorKind}; /// /// let source = "---\nname: greeter\ndescription: says hi\n---\n\n# Greeter\n\n## Say hi\n\nSay hello.\n"; diff --git a/crates/promptforge-parser/src/tests.rs b/crates/promptforge-parser/src/tests.rs index 8be883ea0..7e56498f0 100644 --- a/crates/promptforge-parser/src/tests.rs +++ b/crates/promptforge-parser/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Mutex; -use promptforge_core_support::observe::{NullObserver, Observation, detail}; +use shared_promptforge_api::observe::{NullObserver, Observation, detail}; use super::list::parse_bullet_items; use super::*; diff --git a/crates/promptforge-store/src/error.rs b/crates/promptforge-store/src/error.rs index e83c8aec4..ff91a3c66 100644 --- a/crates/promptforge-store/src/error.rs +++ b/crates/promptforge-store/src/error.rs @@ -294,7 +294,7 @@ impl StoreError { /// Builds [`StoreError::NotFound`] for `path`. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core` test + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api` test /// doubles, which cannot construct the `#[non_exhaustive]` variant /// directly. Not host API. #[doc(hidden)] @@ -307,7 +307,7 @@ impl StoreError { /// Builds [`StoreError::InvalidRange`] for `path` with `reason`. /// - /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-core`'s Lua + /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s Lua /// host, which refuses an `end` without a `start` with the same /// `InvalidRange` a zero bound earns but cannot construct the /// `#[non_exhaustive]` variant directly. Not host API. diff --git a/crates/promptforge-tools/AGENTS.md b/crates/promptforge-tools/AGENTS.md deleted file mode 100644 index 9341b32dd..000000000 --- a/crates/promptforge-tools/AGENTS.md +++ /dev/null @@ -1,6 +0,0 @@ -# promptforge-tools - -This crate contains runtime-agnostic tool vocabulary only. - -- It never depends on transport clients, concrete providers, Lua, a parser, an executor, or a product crate. -- Concrete tool implementations live in provider crates that depend on this vocabulary. diff --git a/crates/promptforge-tools/Cargo.toml b/crates/promptforge-tools/Cargo.toml deleted file mode 100644 index 5f5e21589..000000000 --- a/crates/promptforge-tools/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "promptforge-tools" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "PromptForge tool contract: runtime-agnostic Tool trait, catalog, identity, and output vocabulary" -readme = "README.md" -keywords = ["promptforge", "llm", "tools", "ai", "contract"] -categories = ["api-bindings", "text-processing"] -documentation = "https://cppalliance.github.io/promptforge/" - -[dependencies] -async-trait.workspace = true -serde_json.workspace = true -thiserror.workspace = true - -[lints] -workspace = true diff --git a/crates/promptforge-tools/README.md b/crates/promptforge-tools/README.md deleted file mode 100644 index a8fd6264d..000000000 --- a/crates/promptforge-tools/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# promptforge-tools - -The runtime-agnostic tool contract for the PromptForge pipeline: the [`Tool`] -trait an executable tool implements, the validated [`ToolCatalog`] a harness -builds once and shares across runs, stable [`ToolId`] identity, and the -trust-carrying [`ToolOutput`] / model-safe [`ToolError`] vocabulary. - -This crate holds vocabulary only. Concrete tools (web fetch, web search), -the prompt parser, and the executor live in their own crates and depend on -this one. - -```rust -use promptforge_tools::{ToolCatalog, ToolId}; - -let catalog = ToolCatalog::new(&[])?; -let missing = ToolId::new("promptforge", "web_fetch")?; -assert!(catalog.get(&missing).is_none()); -``` - -License: BSL-1.0 diff --git a/crates/promptforge-web-search/AGENTS.md b/crates/promptforge-web-search/AGENTS.md index 9e938126a..208d08da9 100644 --- a/crates/promptforge-web-search/AGENTS.md +++ b/crates/promptforge-web-search/AGENTS.md @@ -2,7 +2,7 @@ This crate owns the concrete `web_search` tool provider through the Gateway endpoint. -- Tool vocabulary comes from `promptforge-tools`. This provider never depends on Core or a Gateway product crate. +- Tool vocabulary comes from `shared-promptforge-api`'s `tools` module. This provider never depends on Core or a Gateway product crate. - The bearer credential, endpoint validation, request deadline, argument bounds, and response decoding stay in this provider. - Errors preserve their sources: wrap the underlying cause with `ToolError::with_source` instead of flattening it into the message. - Every request is bounded: a fixed deadline on the HTTP client and each outbound call, capped argument sizes, and response bodies that reject a cap overflow rather than truncating. diff --git a/crates/promptforge-web-search/Cargo.toml b/crates/promptforge-web-search/Cargo.toml index e264dd049..56430d270 100644 --- a/crates/promptforge-web-search/Cargo.toml +++ b/crates/promptforge-web-search/Cargo.toml @@ -13,7 +13,7 @@ categories = ["web-programming::http-client", "api-bindings"] documentation = "https://cppalliance.github.io/promptforge/" [dependencies] -promptforge-tools.workspace = true +shared-promptforge-api.workspace = true async-trait.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/promptforge-web-search/README.md b/crates/promptforge-web-search/README.md index 93f80281d..c77f80f75 100644 --- a/crates/promptforge-web-search/README.md +++ b/crates/promptforge-web-search/README.md @@ -15,7 +15,7 @@ promptforge-web-search = "0.1" ```rust use promptforge_web_search::WebSearch; -use promptforge_tools::Tool; +use shared_promptforge_api::tools::Tool; let tool = WebSearch::new("https://gateway.example.com/v1", "bearer-token")?; let output = tool.call(serde_json::json!({ "query": "rust async runtime" })).await?; diff --git a/crates/promptforge-web-search/src/lib.rs b/crates/promptforge-web-search/src/lib.rs index f190519d9..19a49ed64 100644 --- a/crates/promptforge-web-search/src/lib.rs +++ b/crates/promptforge-web-search/src/lib.rs @@ -9,9 +9,9 @@ //! //! The whole supported surface is [`WebSearch`]; the endpoint validation and //! the redacted bearer token are crate-private implementation details. The -//! tool vocabulary ([`Tool`](promptforge_tools::Tool), -//! [`ToolError`](promptforge_tools::ToolError), and their kinds) comes from -//! `promptforge-tools`. +//! tool vocabulary ([`Tool`](shared_promptforge_api::tools::Tool), +//! [`ToolError`](shared_promptforge_api::tools::ToolError), and their kinds) +//! comes from `shared-promptforge-api`. mod endpoint; mod secret; diff --git a/crates/promptforge-web-search/src/web_search.rs b/crates/promptforge-web-search/src/web_search.rs index 98df89409..9b4bac9f7 100644 --- a/crates/promptforge-web-search/src/web_search.rs +++ b/crates/promptforge-web-search/src/web_search.rs @@ -9,7 +9,7 @@ use std::fmt; use std::time::Duration; -use promptforge_tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; +use shared_promptforge_api::tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; use crate::endpoint::Endpoint; use crate::secret::Token; @@ -103,7 +103,7 @@ impl WebSearch { /// /// assert!(WebSearch::new("not-a-url", "bearer-token").is_err()); /// assert!(WebSearch::new("https://gateway.example.com/v1", "").is_err()); - /// # Ok::<(), promptforge_tools::ToolError>(()) + /// # Ok::<(), shared_promptforge_api::tools::ToolError>(()) /// ``` pub fn new(base_url: &str, token: impl Into) -> Result { Self::with_timeout(base_url, token, REQUEST_TIMEOUT) diff --git a/crates/promptforge-web-search/src/web_search/tests.rs b/crates/promptforge-web-search/src/web_search/tests.rs index 2f496aea5..615ad48f1 100644 --- a/crates/promptforge-web-search/src/web_search/tests.rs +++ b/crates/promptforge-web-search/src/web_search/tests.rs @@ -2,7 +2,7 @@ use super::{ MAX_COUNT, MAX_DOMAINS, MAX_ERROR_BODY, MAX_QUERY_LEN, MAX_RESPONSE_BODY, MAX_STRING_LEN, WebSearch, }; -use promptforge_tools::{OutputTrust, Tool, ToolErrorKind, ToolId}; +use shared_promptforge_api::tools::{OutputTrust, Tool, ToolErrorKind, ToolId}; use std::net::SocketAddr; use std::time::Duration; diff --git a/crates/promptforge-webfetch/AGENTS.md b/crates/promptforge-webfetch/AGENTS.md index e4e9456e6..c407227e0 100644 --- a/crates/promptforge-webfetch/AGENTS.md +++ b/crates/promptforge-webfetch/AGENTS.md @@ -4,4 +4,4 @@ This crate fetches and converts one caller-supplied URL into Markdown. - The caller defines URL scope. This provider does not search, crawl, or discover targets. - Every initial request and redirect hop uses the guarded resolver, address pinning, redirect policy, and bounded body handling. No hop may bypass SSRF validation. -- Tool vocabulary comes from `promptforge-tools`. This provider does not depend on Core. +- Tool vocabulary comes from `shared-promptforge-api`'s `tools` module. This provider does not depend on Core. diff --git a/crates/promptforge-webfetch/Cargo.toml b/crates/promptforge-webfetch/Cargo.toml index 439267ae7..3205a4f9b 100644 --- a/crates/promptforge-webfetch/Cargo.toml +++ b/crates/promptforge-webfetch/Cargo.toml @@ -13,7 +13,7 @@ categories = ["web-programming::http-client"] documentation = "https://cppalliance.github.io/promptforge/" [dependencies] -promptforge-tools.workspace = true +shared-promptforge-api.workspace = true async-trait.workspace = true serde_json.workspace = true reqwest = { workspace = true, features = ["gzip", "brotli"] } diff --git a/crates/promptforge-webfetch/README.md b/crates/promptforge-webfetch/README.md index c5dcea4aa..a276e0c5b 100644 --- a/crates/promptforge-webfetch/README.md +++ b/crates/promptforge-webfetch/README.md @@ -15,7 +15,7 @@ promptforge-webfetch = "0.1" ```rust use promptforge_webfetch::WebFetch; -use promptforge_tools::Tool; +use shared_promptforge_api::tools::Tool; let tool = WebFetch::new(); let output = tool.call(serde_json::json!({ "url": "https://example.com" })).await?; diff --git a/crates/promptforge-webfetch/src/error.rs b/crates/promptforge-webfetch/src/error.rs index ad72bf74c..dadfcd1e1 100644 --- a/crates/promptforge-webfetch/src/error.rs +++ b/crates/promptforge-webfetch/src/error.rs @@ -10,7 +10,7 @@ use std::net::IpAddr; -use promptforge_tools::ToolErrorKind; +use shared_promptforge_api::tools::ToolErrorKind; /// How the `Tool::call` boundary should treat a [`FetchError`]. /// @@ -260,7 +260,7 @@ mod tests { use std::error::Error as _; use std::net::IpAddr; - use promptforge_tools::ToolErrorKind; + use shared_promptforge_api::tools::ToolErrorKind; use super::{Disposition, FetchError, SafeUrl}; diff --git a/crates/promptforge-webfetch/src/tool.rs b/crates/promptforge-webfetch/src/tool.rs index ded3e3a35..b5883f932 100644 --- a/crates/promptforge-webfetch/src/tool.rs +++ b/crates/promptforge-webfetch/src/tool.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use reqwest::header::CONTENT_TYPE; -use promptforge_tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; +use shared_promptforge_api::tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; use crate::config::{ConfigError, FetchConfig}; use crate::error::{Disposition, FetchError, SafeUrl}; @@ -38,7 +38,7 @@ type CallResult = Result; /// use promptforge_webfetch::WebFetch; /// /// let tool = WebFetch::new(); -/// let shared: Arc = Arc::new(tool); +/// let shared: Arc = Arc::new(tool); /// assert_eq!(shared.wire_name(), "web_fetch"); /// ``` #[derive(Debug, Clone)] @@ -428,7 +428,7 @@ mod tests { use super::WebFetch; use crate::config::{FetchConfig, FetchConfigBuilder}; use crate::resolver::{Lookup, LookupFuture}; - use promptforge_tools::{Tool, ToolErrorKind, ToolId}; + use shared_promptforge_api::tools::{Tool, ToolErrorKind, ToolId}; /// An article page long enough for readability extraction to fire. const ARTICLE_HTML: &str = r" @@ -1233,7 +1233,7 @@ mod tests { .expect("a mid-stream flat-text failure must be a soft return, not a hard error"); assert_eq!( outcome.trust(), - promptforge_tools::OutputTrust::Untrusted, + shared_promptforge_api::tools::OutputTrust::Untrusted, "a soft body-read failure must be untrusted output" ); let result = outcome.text().to_owned(); diff --git a/crates/promptforge/AGENTS.md b/crates/promptforge/AGENTS.md deleted file mode 100644 index b1871b9cc..000000000 --- a/crates/promptforge/AGENTS.md +++ /dev/null @@ -1,6 +0,0 @@ -# promptforge - -This crate is the PromptForge library product's integrator-facing facade. - -- Keep it facade-only. Do not add logic, new types, wrappers, or substrate dependencies here. -- The `pipeline` and `agent` modules define its public vocabulary. Do not grow a parallel API around the underlying executors. diff --git a/crates/promptforge/Cargo.toml b/crates/promptforge/Cargo.toml deleted file mode 100644 index 1cf133608..000000000 --- a/crates/promptforge/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "promptforge" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "PromptForge integrator facade: pipeline::run for document prompts, agent::run for agent programs" -readme = "README.md" -keywords = ["promptforge", "prompt", "llm", "agent", "ai"] -categories = ["development-tools"] -documentation = "https://cppalliance.github.io/promptforge/" - -[dependencies] -promptforge-agent.workspace = true -promptforge-core.workspace = true - -[lints] -workspace = true diff --git a/crates/promptforge/README.md b/crates/promptforge/README.md deleted file mode 100644 index 8c02cedd7..000000000 --- a/crates/promptforge/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# promptforge - -Integrator-facing facade for the PromptForge library product. Two entry points, no logic of its own. - -## Entry points - -```rust -// Document prompts (.md): sections, prose, the built-in tool loop. -use promptforge::pipeline::{run, RunConfig, RunError}; - -// Agent programs (.lua): the Lua program owns the loop. -use promptforge::agent::{run, AgentConfig, AgentError}; -``` - -Substrate types (parser, store, tools, models) come from their own crates - this package depends only on `promptforge-core` and `promptforge-agent`. diff --git a/crates/promptforge/src/lib.rs b/crates/promptforge/src/lib.rs deleted file mode 100644 index 870bdebe2..000000000 --- a/crates/promptforge/src/lib.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! PromptForge integrator-facing facade. -//! -//! Two entry points: [`pipeline`] for document prompts (`.md`) and [`agent`] -//! for agent programs (`.lua`). This crate re-exports only; it never grows -//! logic or types of its own. -//! -//! Integrators who need substrate types (parser, store, tools, models) -//! depend on those crates directly. - -/// Document prompts (`.md`): sections, prose, the built-in tool loop. -pub mod pipeline { - pub use promptforge_core::execute::run; - pub use promptforge_core::execute::{RunConfig, RunError}; - pub use promptforge_core::input::{ - INPUT_UNAVAILABLE_FALLBACK, InputBroker, InputError, InputOutcome, - }; -} - -/// Agent programs (`.lua`): the Lua program owns the loop. -pub mod agent { - pub use promptforge_agent::{AgentConfig, AgentError, run_agent as run}; -} diff --git a/crates/promptforge/tests/paths.rs b/crates/promptforge/tests/paths.rs deleted file mode 100644 index 32a07a175..000000000 --- a/crates/promptforge/tests/paths.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Compile-level proof that the facade entry paths resolve. - -#[test] -fn pipeline_and_agent_paths_resolve() { - use promptforge::agent::{AgentConfig, AgentError, run as agent_run}; - use promptforge::pipeline::{RunConfig, RunError, run as pipeline_run}; - - // Function items and types must resolve; nothing here is executed for effect. - let _ = std::mem::size_of_val(&pipeline_run); - let _ = std::mem::size_of_val(&agent_run); - let _: Option = None; - let _: Option = None; - let _: Option = None; - let _: Option = None; -} diff --git a/crates/promptforge-core-support/AGENTS.md b/crates/shared-promptforge-api/AGENTS.md similarity index 95% rename from crates/promptforge-core-support/AGENTS.md rename to crates/shared-promptforge-api/AGENTS.md index 05562c5a2..2414f3c2a 100644 --- a/crates/promptforge-core-support/AGENTS.md +++ b/crates/shared-promptforge-api/AGENTS.md @@ -1,4 +1,4 @@ -# promptforge-core-support +# shared-promptforge-api This crate holds shared host-support primitives and canonical runtime-event vocabulary. diff --git a/crates/promptforge-core-support/Cargo.toml b/crates/shared-promptforge-api/Cargo.toml similarity index 89% rename from crates/promptforge-core-support/Cargo.toml rename to crates/shared-promptforge-api/Cargo.toml index 7b72dc5e0..d49ad3268 100644 --- a/crates/promptforge-core-support/Cargo.toml +++ b/crates/shared-promptforge-api/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "promptforge-core-support" +name = "shared-promptforge-api" version.workspace = true edition.workspace = true license.workspace = true @@ -13,9 +13,11 @@ categories = ["rust-patterns"] documentation = "https://cppalliance.github.io/promptforge/" [dependencies] +async-trait.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true +thiserror.workspace = true tokio = { workspace = true, features = ["sync"] } tokio-util.workspace = true diff --git a/crates/promptforge-core-support/README.md b/crates/shared-promptforge-api/README.md similarity index 94% rename from crates/promptforge-core-support/README.md rename to crates/shared-promptforge-api/README.md index c6732207d..d7929d978 100644 --- a/crates/promptforge-core-support/README.md +++ b/crates/shared-promptforge-api/README.md @@ -1,4 +1,4 @@ -# promptforge-core-support +# shared-promptforge-api Small shared host-support primitives for the PromptForge runtime: `untrusted` wraps untrusted external data in a nonce-guarded envelope, diff --git a/crates/promptforge-core-support/src/cancel.rs b/crates/shared-promptforge-api/src/cancel.rs similarity index 99% rename from crates/promptforge-core-support/src/cancel.rs rename to crates/shared-promptforge-api/src/cancel.rs index 08b2df19b..77a5f98e8 100644 --- a/crates/promptforge-core-support/src/cancel.rs +++ b/crates/shared-promptforge-api/src/cancel.rs @@ -37,7 +37,7 @@ tokio::task_local! { /// # Examples /// /// ``` -/// use promptforge_core_support::cancel::CancelHandle; +/// use shared_promptforge_api::cancel::CancelHandle; /// /// let handle = CancelHandle::new(); /// assert!(!handle.is_cancelled()); diff --git a/crates/promptforge-core-support/src/events.rs b/crates/shared-promptforge-api/src/events.rs similarity index 99% rename from crates/promptforge-core-support/src/events.rs rename to crates/shared-promptforge-api/src/events.rs index 8e00fb6f9..3fb583920 100644 --- a/crates/promptforge-core-support/src/events.rs +++ b/crates/shared-promptforge-api/src/events.rs @@ -24,7 +24,7 @@ //! //! # Examples //! ``` -//! use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; +//! use shared_promptforge_api::events::{RuntimeEvent, RuntimeEventKind}; //! //! let event = RuntimeEvent { //! kind: RuntimeEventKind::UserInput, @@ -61,7 +61,7 @@ use serde::{Deserialize, Serialize}; /// /// # Examples /// ``` -/// use promptforge_core_support::events::{EventLog, RuntimeEvent, RuntimeEventKind}; +/// use shared_promptforge_api::events::{EventLog, RuntimeEvent, RuntimeEventKind}; /// /// struct VecLog(Vec); /// diff --git a/crates/promptforge-core-support/src/lib.rs b/crates/shared-promptforge-api/src/lib.rs similarity index 61% rename from crates/promptforge-core-support/src/lib.rs rename to crates/shared-promptforge-api/src/lib.rs index 2f97758b7..9c71f0881 100644 --- a/crates/promptforge-core-support/src/lib.rs +++ b/crates/shared-promptforge-api/src/lib.rs @@ -5,11 +5,20 @@ //! run observes, [`observe`] is the report-only vocabulary a run reports its //! progress through, and [`events`] is the canonical metrics and //! runtime-event vocabulary with the read-side -//! [`EventLog`](events::EventLog) a host may supply as a run input. This +//! [`EventLog`](events::EventLog) a host may supply as a run input. +//! [`models`] is the host-facing model vocabulary (identity, catalog, +//! descriptor) and [`wire`] the streaming delta a host's `on_delta` +//! callback observes. [`tools`] is the runtime-agnostic tool contract: +//! the [`Tool`](tools::Tool) trait, the caller-provided +//! [`ToolCatalog`](tools::ToolCatalog), trusted output, and the model-safe +//! tool error. This //! crate depends on no other promptforge crate, so every promptforge crate //! may depend on it. pub mod cancel; pub mod events; +pub mod models; pub mod observe; +pub mod tools; pub mod untrusted; +pub mod wire; diff --git a/crates/shared-promptforge-api/src/models.rs b/crates/shared-promptforge-api/src/models.rs new file mode 100644 index 000000000..5f4435d5f --- /dev/null +++ b/crates/shared-promptforge-api/src/models.rs @@ -0,0 +1,383 @@ +//! Host-facing model vocabulary: stable identity, catalog, and descriptor. +//! +//! A host builds a [`ModelCatalog`] from gateway `GET /v1/models` (or a +//! pinned offline entry) and names catalog entries by their validated +//! [`ModelId`]. These types carry no transport, binding, or invocation +//! machinery; they are the shared vocabulary every promptforge crate and +//! host may name. + +use std::num::NonZeroU32; + +use serde::Deserialize; + +/// Stable identity of one catalogued model. +/// +/// v0 uses the `"gateway"` namespace plus the caller-facing model name (the +/// gateway `[[model]].name` / OpenAI `id`). +/// +/// `#[non_exhaustive]` so the invariant-bearing identity is only ever built +/// through [`ModelId::new`]/[`ModelId::gateway`], never by a struct literal. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub struct ModelId { + server: String, + name: String, +} + +impl ModelId { + /// The v0 gateway identity namespace. + pub const GATEWAY: &'static str = "gateway"; + + /// Builds an identity from its server namespace and model name. + /// + /// # Errors + /// Returns [`ModelIdError`] if `server` or `name` is empty or contains a + /// control character, so an unusable identity is unrepresentable. + /// + /// # Examples + /// + /// ``` + /// use shared_promptforge_api::models::ModelId; + /// + /// let id = ModelId::new(ModelId::GATEWAY, "claude-sonnet-4-6")?; + /// assert_eq!(id.server(), "gateway"); + /// assert_eq!(id.name(), "claude-sonnet-4-6"); + /// # Ok::<(), shared_promptforge_api::models::ModelIdError>(()) + /// ``` + pub fn new( + server: impl Into, + name: impl Into, + ) -> std::result::Result { + let server = server.into(); + let name = name.into(); + Self::validate("server", &server)?; + Self::validate("name", &name)?; + Ok(Self { server, name }) + } + + /// Builds a gateway-namespaced identity from a caller-facing model name. + /// + /// # Errors + /// Returns [`ModelIdError`] if `name` is empty or contains a control + /// character. + pub fn gateway(name: impl Into) -> std::result::Result { + Self::new(Self::GATEWAY, name) + } + + /// Builds an identity from components already known to be valid. + /// + /// `#[doc(hidden)]`: a cross-crate seam for workspace-internal callers + /// reconstructing an identity from an existing [`ModelId`]'s parts, where + /// [`ModelId::new`]'s validation is redundant. Not host API. + #[doc(hidden)] + pub fn from_validated(server: impl Into, name: impl Into) -> ModelId { + ModelId { + server: server.into(), + name: name.into(), + } + } + + /// The `RS` (U+001E) record separator the model picker uses to delimit + /// encoded identities. Accepting it inside a component would let an id + /// collide or corrupt that encoding, so it is rejected explicitly. + pub(crate) const PICKER_SEPARATOR: char = '\u{001e}'; + + /// Validates one identity component, naming the field in any error. + /// + /// Rejection is by Unicode scalar, not raw byte (MODEL-004): every control + /// character is refused, including C1 controls such as U+0085 (NEL) whose + /// UTF-8 encoding a byte-range scan would miss, and the picker separator + /// U+001E in particular. + fn validate(field: &'static str, value: &str) -> std::result::Result<(), ModelIdError> { + if value.is_empty() { + return Err(ModelIdError { + field, + reason: "must not be empty", + }); + } + if value + .chars() + .any(|c| c.is_control() || c == Self::PICKER_SEPARATOR) + { + return Err(ModelIdError { + field, + reason: "must not contain a control character", + }); + } + Ok(()) + } + + /// Returns the identity namespace. + #[must_use] + pub fn server(&self) -> &str { + &self.server + } + + /// Returns the caller-facing model name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } +} + +/// The reason a [`ModelId`] could not be built from its components. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("invalid model id: {field} {reason}")] +#[non_exhaustive] +pub struct ModelIdError { + /// Which component was rejected (`server` or `name`). + field: &'static str, + /// Why it was rejected. + reason: &'static str, +} + +/// The reason a [`ModelCatalog`] could not be built from its descriptors. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ModelCatalogError { + /// Two descriptors shared one stable [`ModelId`], which would make lookups + /// ambiguous. + #[error("duplicate model identity in catalog: {server}/{name}")] + #[non_exhaustive] + DuplicateId { + /// The repeated identity's server namespace. + server: String, + /// The repeated identity's model name. + name: String, + }, +} + +/// Whether a catalogued model can emit thinking tokens. +/// +/// # Examples +/// +/// ``` +/// use shared_promptforge_api::models::ThinkingMode; +/// +/// // Deserialized from the lowercase gateway wire form. +/// let mode: ThinkingMode = serde_json::from_str("\"switchable\"")?; +/// assert_eq!(mode, ThinkingMode::Switchable); +/// # Ok::<(), serde_json::Error>(()) +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ThinkingMode { + /// The backend never emits thinking tokens. + Never, + /// The backend always emits thinking tokens. + Always, + /// The client may turn thinking on or off per request. + Switchable, +} + +/// One catalogued model with live-resolution metadata. +/// +/// `#[non_exhaustive]` so the descriptor is only ever built through +/// [`ModelDescriptor::new`] and its validated context window is preserved. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ModelDescriptor { + id: ModelId, + description: String, + context: NonZeroU32, + thinking: ThinkingMode, +} + +impl ModelDescriptor { + /// Builds a descriptor from its identity and catalog fields. + /// + /// The context window is a [`NonZeroU32`], so a zero-token window is + /// unrepresentable. + /// + /// # Examples + /// + /// ``` + /// use std::num::NonZeroU32; + /// use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; + /// + /// let context = NonZeroU32::new(131_072).ok_or("context is non-zero")?; + /// let model = ModelDescriptor::new( + /// ModelId::gateway("analyst")?, + /// "A careful analysis model", + /// context, + /// ThinkingMode::Switchable, + /// ); + /// assert_eq!(model.context(), context); + /// assert_eq!(model.thinking(), ThinkingMode::Switchable); + /// # Ok::<(), Box>(()) + /// ``` + #[must_use] + pub fn new( + id: ModelId, + description: impl Into, + context: NonZeroU32, + thinking: ThinkingMode, + ) -> Self { + Self { + id, + description: description.into(), + context, + thinking, + } + } + + /// Returns the stable identity. + #[must_use] + pub fn id(&self) -> &ModelId { + &self.id + } + + /// Returns the prose used for semantic resolve. + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + /// Returns the context window size in tokens (always non-zero). + #[must_use] + pub fn context(&self) -> NonZeroU32 { + self.context + } + + /// Returns the thinking capability. + #[must_use] + pub fn thinking(&self) -> ThinkingMode { + self.thinking + } +} + +/// Complete live model set for one bind pass. +/// +/// `#[non_exhaustive]` so the collision-free catalog invariant is only ever +/// established through [`ModelCatalog::new`]/[`ModelCatalog::empty`]. +// No `Eq`: bindings carry `f64` temperatures transitively. +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct ModelCatalog { + models: Vec, +} + +impl ModelCatalog { + /// Builds a catalog from descriptors in host order. + /// + /// # Errors + /// Returns [`ModelCatalogError::DuplicateId`] when two descriptors share one + /// stable [`ModelId`], so an ambiguous catalog is unrepresentable. + /// + /// # Examples + /// + /// ``` + /// use std::num::NonZeroU32; + /// use shared_promptforge_api::models::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; + /// + /// let ctx = NonZeroU32::new(8_192).ok_or("context is non-zero")?; + /// let id = ModelId::gateway("small")?; + /// let catalog = ModelCatalog::new([ModelDescriptor::new( + /// id.clone(), + /// "A tiny model", + /// ctx, + /// ThinkingMode::Never, + /// )])?; + /// assert!(catalog.contains(&id)); + /// assert_eq!(catalog.models().len(), 1); + /// # Ok::<(), Box>(()) + /// ``` + pub fn new( + models: impl IntoIterator, + ) -> std::result::Result { + let models: Vec = models.into_iter().collect(); + for (index, model) in models.iter().enumerate() { + if models[..index].iter().any(|prior| prior.id() == model.id()) { + return Err(ModelCatalogError::DuplicateId { + server: model.id().server().to_owned(), + name: model.id().name().to_owned(), + }); + } + } + Ok(Self { models }) + } + + /// Builds a catalog from descriptors already known to be collision-free. + /// + /// Used by internal callers whose inputs are already validated, where + /// duplicate checking is redundant. + pub(crate) fn from_validated(models: Vec) -> ModelCatalog { + Self { models } + } + + /// An empty catalog; every `models.bind` resolves as absent. + #[must_use] + pub fn empty() -> Self { + Self::from_validated(Vec::new()) + } + + /// Returns every descriptor. + #[must_use] + pub fn models(&self) -> &[ModelDescriptor] { + &self.models + } + + /// Returns whether the catalog has no entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.models.is_empty() + } + + /// Looks up a descriptor by stable identity. + #[must_use] + pub fn get(&self, id: &ModelId) -> Option<&ModelDescriptor> { + self.models.iter().find(|model| model.id() == id) + } + + /// Returns whether the catalog contains a descriptor with `id`. + #[must_use] + pub fn contains(&self, id: &ModelId) -> bool { + self.get(id).is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_c0_c1_and_picker_separator_controls() { + // The picker record separator (U+001E) must never survive into an id. + assert!(ModelId::new(ModelId::GATEWAY, "a\u{001e}b").is_err()); + // A C1 control (NEL, U+0085) whose UTF-8 bytes (0xC2 0x85) a byte-range + // scan would miss but a scalar `is_control` scan rejects (MODEL-004). + assert!(ModelId::new(ModelId::GATEWAY, "a\u{0085}b").is_err()); + // DEL (U+007F) and NUL are refused too. + assert!(ModelId::new(ModelId::GATEWAY, "a\u{007f}b").is_err()); + assert!(ModelId::new("srv\u{0000}", "name").is_err()); + // A benign multi-byte non-ASCII name is still accepted. + assert!(ModelId::new(ModelId::GATEWAY, "café-模型").is_ok()); + } + + #[test] + fn model_id_rejects_empty_and_control_characters() { + assert!(ModelId::gateway("").is_err()); + assert!(ModelId::new("", "name").is_err()); + assert!(ModelId::new("server", "").is_err()); + assert!(ModelId::new("server", "na\nme").is_err()); + assert!(ModelId::gateway("valid-alias").is_ok()); + } + + #[test] + fn model_catalog_rejects_duplicate_ids() { + let ctx = NonZeroU32::new(8_192).expect("test context window is non-zero"); + let descriptor = |name: &str| { + ModelDescriptor::new( + ModelId::gateway(name).expect("test model alias is valid"), + "d", + ctx, + ThinkingMode::Never, + ) + }; + let err = ModelCatalog::new([descriptor("dup"), descriptor("dup")]) + .expect_err("a catalog with duplicate ids must be rejected"); + assert!(matches!(err, ModelCatalogError::DuplicateId { .. })); + assert!(ModelCatalog::new([descriptor("a"), descriptor("b")]).is_ok()); + } +} diff --git a/crates/promptforge-core-support/src/observe.rs b/crates/shared-promptforge-api/src/observe.rs similarity index 99% rename from crates/promptforge-core-support/src/observe.rs rename to crates/shared-promptforge-api/src/observe.rs index 1d63bb5a1..b45ef4350 100644 --- a/crates/promptforge-core-support/src/observe.rs +++ b/crates/shared-promptforge-api/src/observe.rs @@ -58,7 +58,7 @@ use crate::events::{CallMetrics, ToolCallEvent}; /// wildcard arm (the enum is `#[non_exhaustive]`): /// /// ``` -/// use promptforge_core_support::observe::Observation; +/// use shared_promptforge_api::observe::Observation; /// /// fn describe(event: &Observation) -> String { /// match event { @@ -388,7 +388,7 @@ pub mod detail { /// ``` /// use std::sync::atomic::{AtomicUsize, Ordering}; /// -/// use promptforge_core_support::observe::{Observation, Observer}; +/// use shared_promptforge_api::observe::{Observation, Observer}; /// /// #[derive(Default)] /// struct Counter(AtomicUsize); @@ -419,7 +419,7 @@ pub trait Observer: Send + Sync { /// variants carry no payload and are safe to record. [`Observation`] is /// `#[non_exhaustive]`, so a wildcard arm is required: /// ``` - /// use promptforge_core_support::observe::{Observation, NullObserver, Observer}; + /// use shared_promptforge_api::observe::{Observation, NullObserver, Observer}; /// /// let observer = NullObserver::default(); /// let event = Observation::Lua("author checkpoint text".to_owned()); @@ -541,7 +541,7 @@ pub trait Observer: Send + Sync { /// /// # Examples /// ``` -/// use promptforge_core_support::observe::{Observation, NullObserver, Observer}; +/// use shared_promptforge_api::observe::{Observation, NullObserver, Observer}; /// /// // `#[non_exhaustive]`, so construct it through `Default` rather than the /// // unit literal. diff --git a/crates/promptforge-tools/src/lib.rs b/crates/shared-promptforge-api/src/tools.rs similarity index 87% rename from crates/promptforge-tools/src/lib.rs rename to crates/shared-promptforge-api/src/tools.rs index 07e56cb6f..dc52a9469 100644 --- a/crates/promptforge-tools/src/lib.rs +++ b/crates/shared-promptforge-api/src/tools.rs @@ -6,11 +6,11 @@ //! an executor can dispatch them uniformly. Stable identity ([`ToolId`]) is //! separate from the wire name used by the current model transport. //! -//! This crate holds vocabulary only: the [`Tool`] trait, the caller-provided +//! This module holds vocabulary only: the [`Tool`] trait, the caller-provided //! [`ToolCatalog`], trusted output ([`ToolOutput`], [`OutputTrust`]), the //! model-safe [`ToolError`], and the contract errors. Concrete tool //! implementations, the prompt parser, and the executor live in their own -//! crates and depend on this one. +//! crates and depend on `shared-promptforge-api`. mod ids; mod output; diff --git a/crates/promptforge-tools/src/ids.rs b/crates/shared-promptforge-api/src/tools/ids.rs similarity index 92% rename from crates/promptforge-tools/src/ids.rs rename to crates/shared-promptforge-api/src/tools/ids.rs index cc8393f36..0f3650610 100644 --- a/crates/promptforge-tools/src/ids.rs +++ b/crates/shared-promptforge-api/src/tools/ids.rs @@ -23,12 +23,12 @@ impl ToolId { /// # Examples /// /// ``` - /// use promptforge_tools::ToolId; + /// use shared_promptforge_api::tools::ToolId; /// /// let id = ToolId::new("promptforge", "web_fetch")?; /// assert_eq!(id.server(), "promptforge"); /// assert_eq!(id.name(), "web_fetch"); - /// # Ok::<(), promptforge_tools::ToolIdError>(()) + /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` pub fn new(server: impl Into, name: impl Into) -> Result { let server = server.into(); @@ -62,11 +62,11 @@ impl ToolId { /// # Examples /// /// ``` - /// use promptforge_tools::ToolId; + /// use shared_promptforge_api::tools::ToolId; /// /// let id = ToolId::new("promptforge", "web_fetch")?; /// assert_eq!(id.server(), "promptforge"); - /// # Ok::<(), promptforge_tools::ToolIdError>(()) + /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` #[must_use] pub fn server(&self) -> &str { @@ -78,11 +78,11 @@ impl ToolId { /// # Examples /// /// ``` - /// use promptforge_tools::ToolId; + /// use shared_promptforge_api::tools::ToolId; /// /// let id = ToolId::new("promptforge", "web_fetch")?; /// assert_eq!(id.name(), "web_fetch"); - /// # Ok::<(), promptforge_tools::ToolIdError>(()) + /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` #[must_use] pub fn name(&self) -> &str { @@ -131,7 +131,7 @@ impl ToolIdError { } /// The crate-internal human-readable reason, reused when a wire-name - /// rejection is re-reported as a [`crate::ToolCatalogError`]. + /// rejection is re-reported as a [`crate::tools::ToolCatalogError`]. pub(crate) fn reason(&self) -> &'static str { self.reason } diff --git a/crates/promptforge-tools/src/output.rs b/crates/shared-promptforge-api/src/tools/output.rs similarity index 88% rename from crates/promptforge-tools/src/output.rs rename to crates/shared-promptforge-api/src/tools/output.rs index 12b4dffd5..f77f8befb 100644 --- a/crates/promptforge-tools/src/output.rs +++ b/crates/shared-promptforge-api/src/tools/output.rs @@ -14,7 +14,7 @@ pub enum OutputTrust { Untrusted, } -/// The result of a successful [`Tool::call`](crate::Tool::call), +/// The result of a successful [`Tool::call`](crate::tools::Tool::call), /// carrying its text and trust. /// /// Trust travels with the value so the executor never has to remember a @@ -32,7 +32,7 @@ impl ToolOutput { /// /// # Examples /// ``` - /// use promptforge_tools::{OutputTrust, ToolOutput}; + /// use shared_promptforge_api::tools::{OutputTrust, ToolOutput}; /// /// let out = ToolOutput::trusted("done"); /// assert_eq!(out.trust(), OutputTrust::Trusted); @@ -50,7 +50,7 @@ impl ToolOutput { /// /// # Examples /// ``` - /// use promptforge_tools::{OutputTrust, ToolOutput}; + /// use shared_promptforge_api::tools::{OutputTrust, ToolOutput}; /// /// let out = ToolOutput::untrusted("..."); /// assert_eq!(out.trust(), OutputTrust::Untrusted); @@ -67,7 +67,7 @@ impl ToolOutput { /// /// # Examples /// ``` - /// use promptforge_tools::ToolOutput; + /// use shared_promptforge_api::tools::ToolOutput; /// /// assert_eq!(ToolOutput::trusted("hi").text(), "hi"); /// ``` @@ -80,7 +80,7 @@ impl ToolOutput { /// /// # Examples /// ``` - /// use promptforge_tools::{OutputTrust, ToolOutput}; + /// use shared_promptforge_api::tools::{OutputTrust, ToolOutput}; /// /// assert_eq!(ToolOutput::untrusted("x").trust(), OutputTrust::Untrusted); /// ``` @@ -106,7 +106,7 @@ pub enum ToolErrorKind { Other, } -/// A narrow, model-safe error from a [`Tool::call`](crate::Tool::call). +/// A narrow, model-safe error from a [`Tool::call`](crate::tools::Tool::call). /// /// The `Display` message is caller-facing and safe to hand back to the model; /// any underlying cause is hidden behind [`std::error::Error::source`]. Match on @@ -124,7 +124,7 @@ impl ToolError { /// /// # Examples /// ``` - /// use promptforge_tools::{ToolError, ToolErrorKind}; + /// use shared_promptforge_api::tools::{ToolError, ToolErrorKind}; /// /// let err = ToolError::message("could not read the page"); /// assert_eq!(err.kind(), ToolErrorKind::Other); @@ -145,7 +145,7 @@ impl ToolError { /// /// # Examples /// ``` - /// use promptforge_tools::{ToolError, ToolErrorKind}; + /// use shared_promptforge_api::tools::{ToolError, ToolErrorKind}; /// /// let io = std::io::Error::other("boom"); /// let err = ToolError::with_source("backend failed", io); @@ -168,7 +168,7 @@ impl ToolError { /// /// # Examples /// ``` - /// use promptforge_tools::{ToolError, ToolErrorKind}; + /// use shared_promptforge_api::tools::{ToolError, ToolErrorKind}; /// /// let err = ToolError::message("bad args").with_kind(ToolErrorKind::InvalidArguments); /// assert_eq!(err.kind(), ToolErrorKind::InvalidArguments); @@ -189,7 +189,7 @@ impl ToolError { /// /// # Examples /// ``` - /// use promptforge_tools::{ToolError, ToolErrorKind}; + /// use shared_promptforge_api::tools::{ToolError, ToolErrorKind}; /// /// let err = ToolError::message("stopped").with_kind(ToolErrorKind::Cancelled); /// assert!(err.is_cancelled()); @@ -203,7 +203,7 @@ impl ToolError { /// /// # Examples /// ``` - /// use promptforge_tools::{ToolError, ToolErrorKind}; + /// use shared_promptforge_api::tools::{ToolError, ToolErrorKind}; /// /// let err = ToolError::message("timeout").with_kind(ToolErrorKind::Transport); /// assert!(err.is_retryable()); diff --git a/crates/promptforge-tools/src/registry.rs b/crates/shared-promptforge-api/src/tools/registry.rs similarity index 95% rename from crates/promptforge-tools/src/registry.rs rename to crates/shared-promptforge-api/src/tools/registry.rs index 2800a5a1e..f0cfeef78 100644 --- a/crates/promptforge-tools/src/registry.rs +++ b/crates/shared-promptforge-api/src/tools/registry.rs @@ -48,11 +48,11 @@ impl ToolCatalog { /// # Examples /// /// ``` - /// use promptforge_tools::ToolCatalog; + /// use shared_promptforge_api::tools::ToolCatalog; /// /// let catalog = ToolCatalog::new(&[])?; /// assert!(catalog.tools().is_empty()); - /// # Ok::<(), promptforge_tools::ToolCatalogError>(()) + /// # Ok::<(), shared_promptforge_api::tools::ToolCatalogError>(()) /// ``` pub fn new(tools: &[Arc]) -> Result { let mut seen = std::collections::BTreeSet::new(); @@ -86,7 +86,7 @@ impl ToolCatalog { /// # Examples /// /// ``` - /// use promptforge_tools::{ToolCatalog, ToolId}; + /// use shared_promptforge_api::tools::{ToolCatalog, ToolId}; /// /// let catalog = ToolCatalog::new(&[])?; /// let missing = ToolId::new("promptforge", "missing")?; @@ -106,11 +106,11 @@ impl ToolCatalog { /// # Examples /// /// ``` - /// use promptforge_tools::ToolCatalog; + /// use shared_promptforge_api::tools::ToolCatalog; /// /// let catalog = ToolCatalog::new(&[])?; /// assert!(catalog.tools().is_empty()); - /// # Ok::<(), promptforge_tools::ToolCatalogError>(()) + /// # Ok::<(), shared_promptforge_api::tools::ToolCatalogError>(()) /// ``` #[must_use] pub fn tools(&self) -> &[Arc] { @@ -186,7 +186,7 @@ impl ToolCatalogError { /// [`call`](Tool::call). A minimal doctested implementation: /// /// ``` -/// use promptforge_tools::{ +/// use shared_promptforge_api::tools::{ /// OutputTrust, Tool, ToolError, ToolErrorKind, ToolId, ToolOutput, /// }; /// @@ -228,7 +228,7 @@ impl ToolCatalogError { /// assert_eq!(echo.wire_name(), "echo"); /// assert_eq!(echo.id().server(), "example"); /// # let _ = OutputTrust::Trusted; -/// # Ok::<(), promptforge_tools::ToolIdError>(()) +/// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` /// /// # Compatibility policy @@ -293,9 +293,9 @@ pub trait Tool: Send + Sync { /// Execute the tool with the given JSON arguments and return its output. /// /// The returned [`ToolOutput`] carries its own - /// [`OutputTrust`](crate::OutputTrust), so trust is mandatory and + /// [`OutputTrust`](crate::tools::OutputTrust), so trust is mandatory and /// cannot be forgotten: an - /// [`OutputTrust::Untrusted`](crate::OutputTrust::Untrusted) result + /// [`OutputTrust::Untrusted`](crate::tools::OutputTrust::Untrusted) result /// is nonce-wrapped before it can reach model input. A failure returns a /// narrow, model-safe [`ToolError`]. Implementations must not panic and /// should return promptly when the run is cancelled. diff --git a/crates/promptforge-tools/src/tests.rs b/crates/shared-promptforge-api/src/tools/tests.rs similarity index 97% rename from crates/promptforge-tools/src/tests.rs rename to crates/shared-promptforge-api/src/tools/tests.rs index 1d94439b1..176074e27 100644 --- a/crates/promptforge-tools/src/tests.rs +++ b/crates/shared-promptforge-api/src/tools/tests.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use crate::{Tool, ToolCatalog, ToolCatalogErrorKind, ToolError, ToolId, ToolOutput}; +use super::{Tool, ToolCatalog, ToolCatalogErrorKind, ToolError, ToolId, ToolOutput}; fn inspect_id() -> ToolId { ToolId::new("fixtures", "inspect").expect("fixture id is valid") @@ -81,7 +81,7 @@ fn trait_is_dyn_compatible() { #[test] fn tool_output_carries_mandatory_trust() { - use crate::{OutputTrust, ToolOutput}; + use super::{OutputTrust, ToolOutput}; assert_eq!(ToolOutput::trusted("a").trust(), OutputTrust::Trusted); assert_eq!(ToolOutput::untrusted("b").trust(), OutputTrust::Untrusted); assert_eq!(ToolOutput::trusted("a").text(), "a"); @@ -98,7 +98,7 @@ fn tool_catalog_is_send_and_sync() { #[test] fn tool_error_classifies_and_hides_source() { - use crate::{ToolError, ToolErrorKind}; + use super::{ToolError, ToolErrorKind}; fn assert_send_sync() {} assert_send_sync::(); @@ -222,7 +222,7 @@ fn catalog_rejects_duplicate_tool_ids() { #[test] fn tool_id_new_rejects_empty_separator_and_control() { - use crate::ToolIdErrorKind; + use super::ToolIdErrorKind; assert_eq!( ToolId::new("", "name").expect_err("empty server").kind(), diff --git a/crates/promptforge-core-support/src/untrusted.rs b/crates/shared-promptforge-api/src/untrusted.rs similarity index 98% rename from crates/promptforge-core-support/src/untrusted.rs rename to crates/shared-promptforge-api/src/untrusted.rs index f1a9d11a0..e2cf3bee8 100644 --- a/crates/promptforge-core-support/src/untrusted.rs +++ b/crates/shared-promptforge-api/src/untrusted.rs @@ -120,15 +120,6 @@ fn preface(nonce: &GuardNonce) -> String { ) } -/// Wraps `content` in a self-contained guard block under the run's `nonce`. -/// -/// Deprecated alias for [`GuardNonce::wrap`]. -#[deprecated(since = "0.2.0", note = "use GuardNonce::wrap")] -#[must_use] -pub fn wrap(nonce: &GuardNonce, content: &str) -> String { - nonce.wrap(content) -} - /// Escapes every literal `<` so content cannot introduce any live markup tag, /// then neutralizes the control markup that survives escaping. /// diff --git a/crates/promptforge-core-support/src/untrusted/inventory.rs b/crates/shared-promptforge-api/src/untrusted/inventory.rs similarity index 100% rename from crates/promptforge-core-support/src/untrusted/inventory.rs rename to crates/shared-promptforge-api/src/untrusted/inventory.rs diff --git a/crates/shared-promptforge-api/src/wire.rs b/crates/shared-promptforge-api/src/wire.rs new file mode 100644 index 000000000..cd62254ae --- /dev/null +++ b/crates/shared-promptforge-api/src/wire.rs @@ -0,0 +1,17 @@ +//! Wire-adjacent vocabulary a host names in streaming callbacks. + +/// One live increment from a streaming completion. +/// +/// The client's completion call invokes its delta callback with these as the +/// stream arrives: answer text and the reasoning side channel stay separated +/// so a consumer can render them differently. Tool-call fragments are never +/// surfaced as deltas; they buffer inside the client until the batch is +/// complete and validated. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum StreamDelta { + /// A fragment of the assistant's answer text. + Text(String), + /// A fragment of the reasoning side channel, never part of the answer. + Reasoning(String), +} diff --git a/crates/workshop-gateway/Cargo.toml b/crates/workshop-gateway/Cargo.toml index 8a770f107..37b826950 100644 --- a/crates/workshop-gateway/Cargo.toml +++ b/crates/workshop-gateway/Cargo.toml @@ -14,12 +14,12 @@ test-fixtures = ["dep:tempfile"] [dependencies] arc-swap.workspace = true futures-util.workspace = true -promptforge-core-support.workspace = true -promptforge-model-client.workspace = true +shared-promptforge-api.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true -shared-progress.workspace = true +# The serde feature decodes the gateway's progress event stream. +shared-progress = { workspace = true, features = ["serde"] } shared-sidecar.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/workshop-gateway/src/gateway.rs b/crates/workshop-gateway/src/gateway.rs index 830d5747c..514377fc5 100644 --- a/crates/workshop-gateway/src/gateway.rs +++ b/crates/workshop-gateway/src/gateway.rs @@ -10,6 +10,7 @@ use std::time::Duration; mod events; +pub(crate) mod progress; mod sse; pub mod socket; @@ -18,6 +19,7 @@ pub use events::{ CacheEvent, CacheResponse, ForwardedResponse, GatewayResponse, SsePayloadStream, SwitchEvent, SwitchEventStream, SwitchResponse, switch_events, }; +pub use progress::ProgressEventStream; pub use socket::GatewayRealtimeSocket; use sse::{is_event_stream, payload_stream, read}; @@ -57,6 +59,30 @@ pub enum GatewayError { #[non_exhaustive] #[error("read gateway response body")] ReadBody(#[source] Box), + + /// The gateway answered a streaming-only request with a non-success + /// status (for example 401 on a rejected token). The body is bounded + /// and control-escaped. + #[non_exhaustive] + #[error("gateway answered status {status}: {body}")] + Status { + /// The gateway's status code. + status: reqwest::StatusCode, + /// The gateway's error body, bounded and control-escaped. + body: String, + }, + + /// A gateway event stream carried a block that could not be decoded, + /// or one that grew past its size bound without terminating. + #[non_exhaustive] + #[error("malformed gateway event: {message}")] + Malformed { + /// What was wrong with the block. + message: String, + /// The decode failure, when the block was undecodable. + #[source] + source: Option>, + }, } impl GatewayError { @@ -349,6 +375,36 @@ impl GatewayClient { } read(response).await.map(CacheResponse::Buffered) } + + /// Subscribes to the gateway's `GET /admin/progress` event stream. + /// + /// The returned stream yields every progress event the gateway + /// sends, beginning with the snapshot replay of the operations live + /// at connect time. Heartbeat comment lines and other non-`data:` + /// lines are skipped. Intermediate events are lossy at the source, + /// so the stream promises no completeness; detect completion only + /// from `Finished` events, never from a fraction reaching 1.0. Only + /// the wait for the response headers is bounded: the subscription is + /// long-lived by design, so the stream itself carries no deadline. + /// The stream ends when the gateway closes the body; whether to + /// resubscribe is the caller's decision. + /// + /// The endpoint answers only an event stream on success, so a + /// non-success status (for example 401 on a rejected token) is a + /// [`GatewayError::Status`], not a relayed response. Decode failures + /// surface as per-item errors instead. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed (the header bound elapsing included), + /// [`GatewayError::Status`] on a non-success status, and + /// [`GatewayError::ReadBody`] when that answer's body cannot be + /// read. + pub async fn subscribe_progress(&self) -> Result { + let request = self.authorize(self.http.get(format!("{}/admin/progress", self.base_url))); + let response = self.send_bounded(request).await?; + progress::subscribe(response).await + } } #[cfg(test)] diff --git a/crates/workshop-gateway/src/gateway/progress.rs b/crates/workshop-gateway/src/gateway/progress.rs new file mode 100644 index 000000000..79824aa53 --- /dev/null +++ b/crates/workshop-gateway/src/gateway/progress.rs @@ -0,0 +1,190 @@ +//! The `GET /admin/progress` subscription: a long-lived SSE stream of +//! [`ProgressEvent`]s, decoded block-by-block under a hard size bound. +//! +//! Unlike the switch and cache streams, a progress subscription never +//! terminates on its own and carries events the workshop imports into +//! the progress hub verbatim, so the decode keeps the stricter posture +//! the subscriber always had: only blank-line-terminated blocks +//! dispatch (an incomplete trailing block is discarded), and a block +//! that grows past `MAX_EVENT_BLOCK` without its terminator is +//! refused rather than buffered unbounded. + +use std::pin::Pin; + +use futures_util::Stream; +use shared_progress::ProgressEvent; + +use super::GatewayError; + +/// The largest single SSE event block buffered before the stream refuses +/// it, in bytes. A peer that never sends a blank-line terminator would +/// otherwise grow the reassembly buffer unbounded; sized well above any +/// realistic progress event. +pub(crate) const MAX_EVENT_BLOCK: usize = 1024 * 1024; + +/// The largest error body kept for a subscription diagnostic, in bytes. +const MAX_ERROR_BODY: usize = 2000; + +/// A stream of decoded [`ProgressEvent`]s from the gateway, in arrival +/// order. +/// +/// A `data:` block that does not decode is yielded as one error item +/// without ending the stream; a read failure or an event block oversized +/// beyond `MAX_EVENT_BLOCK` is yielded as one error item that ends the +/// stream. The stream ends when the gateway closes the body; whether to +/// resubscribe is the caller's decision. +pub type ProgressEventStream = + Pin> + Send>>; + +/// Turns an answered `GET /admin/progress` request into the event stream. +/// +/// The endpoint answers only an event stream on success, so a +/// non-success status is [`GatewayError::Status`] carrying a bounded, +/// control-escaped body rather than a relayed response. +pub(super) async fn subscribe( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(GatewayError::Status { + status, + body: error_body(response).await?, + }); + } + Ok(decode(response)) +} + +/// Reads at most [`MAX_ERROR_BODY`] bytes of a non-success response +/// body, escaping control characters so a hostile body cannot forge log +/// lines or smuggle terminal control sequences into a diagnostic. +async fn error_body(mut response: reqwest::Response) -> Result { + let mut buffer: Vec = Vec::new(); + while buffer.len() < MAX_ERROR_BODY { + match response.chunk().await { + Ok(Some(chunk)) => { + let take = (MAX_ERROR_BODY - buffer.len()).min(chunk.len()); + buffer.extend_from_slice(&chunk[..take]); + if take < chunk.len() { + break; + } + } + Ok(None) => break, + Err(source) => return Err(GatewayError::ReadBody(Box::new(source))), + } + } + if buffer.is_empty() { + return Ok("(empty body)".to_owned()); + } + let lossy = String::from_utf8_lossy(&buffer); + let mut escaped = String::with_capacity(lossy.len()); + for ch in lossy.chars() { + if ch.is_control() { + escaped.extend(ch.escape_default()); + } else { + escaped.push(ch); + } + } + Ok(escaped) +} + +/// Decodes an SSE body into a progress-event stream: chunks are buffered +/// until a blank line terminates an event block (LF or CRLF line endings +/// alike), comment-only blocks (heartbeats) are skipped, and an +/// undecodable block becomes one error item rather than killing the +/// stream. A mid-stream read failure likewise surfaces as one error +/// item, after which the stream ends. A block that grows past +/// [`MAX_EVENT_BLOCK`] without a terminator is refused as one error +/// item, after which the stream ends, so a peer cannot buffer the client +/// unbounded. +fn decode(response: reqwest::Response) -> ProgressEventStream { + let events = futures_util::stream::unfold( + (response, Vec::new(), false), + |(mut response, mut buffer, mut failed)| async move { + loop { + if let Some(item) = next_buffered_event(&mut buffer) { + return Some((item, (response, buffer, failed))); + } + if failed { + return None; + } + match response.chunk().await { + Ok(Some(chunk)) => { + if buffer.len() + chunk.len() > MAX_EVENT_BLOCK { + failed = true; + let item = Err(GatewayError::Malformed { + message: format!( + "progress event block exceeds the {MAX_EVENT_BLOCK}-byte limit" + ), + source: None, + }); + return Some((item, (response, buffer, failed))); + } + buffer.extend_from_slice(&chunk); + } + // An incomplete trailing block is discarded, matching + // the SSE rule that only blank-line-terminated blocks + // dispatch. + Ok(None) => return None, + Err(source) => { + failed = true; + let item = Err(GatewayError::ReadBody(Box::new(source))); + return Some((item, (response, buffer, failed))); + } + } + } + }, + ); + Box::pin(events) +} + +/// Pops the next decodable event out of `buffer`, or `None` when no +/// complete block is buffered yet. Comment-only blocks (heartbeats) are +/// consumed and skipped. +fn next_buffered_event(buffer: &mut Vec) -> Option> { + loop { + let end = block_end(buffer)?; + let block: Vec = buffer.drain(..end).collect(); + if let Some(item) = parse_event_block(&block) { + return Some(item); + } + } +} + +/// The end (terminator included) of the first complete event block: a +/// blank line, whether the peer terminates its lines with LF or CRLF. +fn block_end(buffer: &[u8]) -> Option { + let lf = buffer + .windows(2) + .position(|pair| pair == b"\n\n") + .map(|at| at + 2); + let crlf = buffer + .windows(4) + .position(|quad| quad == b"\r\n\r\n") + .map(|at| at + 4); + [lf, crlf].into_iter().flatten().min() +} + +/// Decodes one SSE event block: `data:` lines join into the payload, +/// comment lines and unrecognized fields are ignored, and a block with +/// no payload (a heartbeat) yields `None`. +fn parse_event_block(block: &[u8]) -> Option> { + let mut data: Vec = Vec::new(); + for line in block.split(|byte| *byte == b'\n') { + let line = line.strip_suffix(b"\r").unwrap_or(line); + if let Some(rest) = line.strip_prefix(b"data:") { + if !data.is_empty() { + data.push(b'\n'); + } + data.extend_from_slice(rest.strip_prefix(b" ").unwrap_or(rest)); + } + } + if data.is_empty() { + return None; + } + Some( + serde_json::from_slice(&data).map_err(|source| GatewayError::Malformed { + message: "progress event was not valid JSON".to_owned(), + source: Some(Box::new(source)), + }), + ) +} diff --git a/crates/workshop-gateway/src/gateway/tests.rs b/crates/workshop-gateway/src/gateway/tests.rs index 8b2e0c905..8515c6c33 100644 --- a/crates/workshop-gateway/src/gateway/tests.rs +++ b/crates/workshop-gateway/src/gateway/tests.rs @@ -6,6 +6,7 @@ use super::*; mod cache; mod decoder; +mod progress; mod switch; mod timeouts; diff --git a/crates/workshop-gateway/src/gateway/tests/progress.rs b/crates/workshop-gateway/src/gateway/tests/progress.rs new file mode 100644 index 000000000..3e0b8fec2 --- /dev/null +++ b/crates/workshop-gateway/src/gateway/tests/progress.rs @@ -0,0 +1,260 @@ +//! Progress-subscription tests: the `GET /admin/progress` event stream +//! decodes block-by-block, heartbeat comments are skipped, a malformed +//! block degrades to one error item without ending the stream, a read +//! failure or an oversized block ends it, and a non-success status is an +//! error rather than a stream. + +use super::*; + +use futures_util::StreamExt as _; + +use shared_progress::{EventState, ProgressEvent}; + +use crate::gateway::progress::MAX_EVENT_BLOCK; + +/// Serializes a wire-format progress event by hand, so the tests pin +/// the JSON shape the gateway emits rather than the progress crate's +/// constructors. +fn event_json(state: &serde_json::Value) -> String { + serde_json::json!({ + "operation": 7, + "path": "local-models/ggml/download", + "label": "Download", + "state": state, + }) + .to_string() +} + +/// A mock `GET /admin/progress` that requires the bearer token and +/// answers with `body` as the verbatim SSE payload. +fn mock_progress(body: String) -> axum::Router { + use axum::Router; + use axum::response::IntoResponse; + use axum::routing::get; + + Router::new().route( + "/admin/progress", + get(move |headers: axum::http::HeaderMap| { + let body = body.clone(); + async move { + let auth = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()); + if auth != Some("Bearer tok") { + return (axum::http::StatusCode::UNAUTHORIZED, "bad token").into_response(); + } + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + body, + ) + .into_response() + } + }), + ) +} + +/// Subscribes against `app` and collects the whole event stream. +async fn collect_events(app: axum::Router) -> Vec> { + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "tok").expect("client builds in tests"); + let events = client + .subscribe_progress() + .await + .expect("a well-formed stream subscribes"); + events.collect().await +} + +#[tokio::test] +async fn subscribe_progress_decodes_events_and_skips_heartbeat_comments() { + let begun = event_json(&serde_json::json!({"Begun": {"weight": 2.0}})); + let updated = event_json(&serde_json::json!({"Updated": {"fraction": 0.5}})); + let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); + let body = format!( + ": heartbeat\n\ndata: {begun}\n\ndata: {updated}\n\n: heartbeat\n\ndata: {finished}\n\n" + ); + + let events = collect_events(mock_progress(body)).await; + + let states: Vec = events + .iter() + .map(|item| item.as_ref().expect("every item decodes").state) + .collect(); + assert_eq!( + states, + vec![ + EventState::Begun { weight: 2.0 }, + EventState::Updated { fraction: 0.5 }, + EventState::Finished { ok: true }, + ] + ); +} + +#[tokio::test] +async fn subscribe_progress_classifies_a_non_success_status() { + let base_url = serve(mock_progress(String::new())).await; + let client = GatewayClient::new(&base_url, "wrong-token").expect("client builds in tests"); + + let Err(error) = client.subscribe_progress().await else { + panic!("a 401 response must surface as an error"); + }; + let GatewayError::Status { status, body } = &error else { + panic!("a 401 response is a status error, got {error}"); + }; + assert_eq!(status.as_u16(), 401); + assert_eq!(body, "bad token"); +} + +#[tokio::test] +async fn subscribe_progress_yields_one_error_per_bad_event_and_continues() { + let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); + let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); + let body = format!("data: {begun}\n\ndata: {{not json\n\ndata: {finished}\n\n"); + + let events = collect_events(mock_progress(body)).await; + + assert_eq!(events.len(), 3, "one item per data block"); + assert!(events[0].is_ok(), "the leading event decodes"); + let error = events[1] + .as_ref() + .expect_err("the undecodable line is one error item"); + assert!( + matches!(error, GatewayError::Malformed { .. }), + "an undecodable block is a malformed error, got {error}" + ); + assert!( + events[2].is_ok(), + "a bad line must not end the stream: the trailing event still arrives" + ); +} + +#[tokio::test] +async fn subscribe_progress_reassembles_an_event_split_across_chunks() { + let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); + let wire = format!("data: {begun}\n\n"); + let (head, tail) = wire.split_at(wire.len() / 2); + let (head, tail) = (head.to_owned(), tail.to_owned()); + let app = axum::Router::new().route( + "/admin/progress", + axum::routing::get(move || { + let chunks = vec![ + Ok::<_, std::convert::Infallible>(head.clone()), + Ok::<_, std::convert::Infallible>(tail.clone()), + ]; + async move { + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + axum::body::Body::from_stream(futures_util::stream::iter(chunks)), + ) + } + }), + ); + + let events = collect_events(app).await; + + assert_eq!(events.len(), 1, "the split block decodes as one event"); + assert!(events[0].is_ok(), "the reassembled event decodes"); +} + +#[tokio::test] +async fn subscribe_progress_yields_one_error_on_a_mid_stream_read_failure_then_ends() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // The server promises a large body, delivers one complete event, then + // drops the connection: the read failure must surface as one error + // item that ends the stream, not as a hang or a silent close. + let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((mut sock, _)) = listener.accept().await { + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf).await; + let header = "HTTP/1.1 200 OK\r\n\ + Content-Type: text/event-stream\r\n\ + Content-Length: 1000000\r\n\r\n"; + let _ = sock.write_all(header.as_bytes()).await; + let _ = sock + .write_all(format!("data: {begun}\n\n").as_bytes()) + .await; + // Socket drops here: the promised body never completes. + } + }); + let client = GatewayClient::new(&format!("http://{addr}"), "tok").expect("client builds"); + let events = client + .subscribe_progress() + .await + .expect("a well-formed stream subscribes") + .collect::>() + .await; + + assert_eq!(events.len(), 2, "the decoded event, then one error item"); + assert!(events[0].is_ok(), "the leading event decodes"); + let error = events[1] + .as_ref() + .expect_err("the truncated body is one error item"); + assert!( + matches!(error, GatewayError::ReadBody(_)), + "a mid-stream read failure is a body-read error, got {error}" + ); +} + +#[tokio::test] +async fn subscribe_progress_bounds_an_event_block_that_never_terminates() { + let body = "x".repeat(MAX_EVENT_BLOCK + 1); + + let events = collect_events(mock_progress(body)).await; + + assert_eq!(events.len(), 1, "the oversized block is one error item"); + let error = events[0] + .as_ref() + .expect_err("an unterminated oversized block must be refused"); + assert!( + matches!(error, GatewayError::Malformed { .. }), + "an oversized block is a malformed error, got {error}" + ); + assert!( + error.to_string().contains("exceeds"), + "the bound must report the size limit, got {error}" + ); +} + +#[tokio::test] +async fn subscribe_progress_decodes_crlf_terminated_blocks() { + // A peer that terminates its lines with CRLF still dispatches: the + // blank-line terminator is `\r\n\r\n`, which contains no `\n\n`. + let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); + let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); + let body = format!("data: {begun}\r\n\r\ndata: {finished}\r\n\r\n"); + + let events = collect_events(mock_progress(body)).await; + + let states: Vec = events + .iter() + .map(|item| item.as_ref().expect("every item decodes").state) + .collect(); + assert_eq!( + states, + vec![ + EventState::Begun { weight: 1.0 }, + EventState::Finished { ok: true }, + ] + ); +} + +#[tokio::test] +async fn subscribe_progress_discards_an_incomplete_trailing_block() { + // The body ends mid-block: only blank-line-terminated blocks + // dispatch, so the partial event is dropped and the stream ends. + let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); + let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); + let body = format!("data: {begun}\n\ndata: {finished}"); + + let events = collect_events(mock_progress(body)).await; + + assert_eq!( + events.len(), + 1, + "the unterminated trailing block is discarded" + ); + assert!(events[0].is_ok(), "the complete event decodes"); +} diff --git a/crates/workshop-gateway/src/gateway_binding.rs b/crates/workshop-gateway/src/gateway_binding.rs index d82d02325..311014bb2 100644 --- a/crates/workshop-gateway/src/gateway_binding.rs +++ b/crates/workshop-gateway/src/gateway_binding.rs @@ -1,7 +1,7 @@ //! Atomically replaceable Gateway endpoint and credential state. //! //! Every Gateway-dependent Workshop path loads one immutable snapshot -//! containing the HTTP client, model client, base URL, bearer, and generation. +//! containing the HTTP client, base URL, bearer, and generation. //! A local-sidecar replacement builds the complete next snapshot before one //! atomic store, then notifies long-lived tasks to reconnect. Explicitly //! configured endpoints never receive an updater from the desktop shell. @@ -13,9 +13,6 @@ use std::fmt; use std::sync::{Arc, Mutex, PoisonError}; use arc_swap::ArcSwap; -use promptforge_model_client::client::{ - GatewayClient as ModelClient, GatewayEndpoint, SecretString, -}; use tokio::sync::watch; use crate::gateway::{GatewayClient, GatewayError}; @@ -26,10 +23,9 @@ pub struct GatewaySnapshot { client: GatewayClient, /// Normalized Gateway base URL paired with both clients. base_url: String, - /// Bearer paired with `client`, retained for the progress subscriber. + /// Bearer paired with `client`, exposed for consumers that authenticate + /// outside the HTTP client. api_key: String, - /// Agent completion client built from the same URL and bearer. - model_client: Option, /// Monotonic generation assigned before this snapshot is published. generation: u64, /// Proven local Gateway boot, absent for an explicitly configured endpoint. @@ -43,7 +39,6 @@ impl fmt::Debug for GatewaySnapshot { .field("client", &self.client) .field("base_url", &self.base_url) .field("api_key", &"") - .field("model_client", &"") .field("generation", &self.generation) .field("identity", &self.identity) .finish_non_exhaustive() @@ -57,12 +52,6 @@ impl GatewaySnapshot { &self.client } - /// The agent model client from the same endpoint and credential pair. - #[must_use] - pub fn model_client(&self) -> Option { - self.model_client.clone() - } - /// The Gateway base URL in this generation. #[must_use] pub fn base_url(&self) -> &str { @@ -151,14 +140,12 @@ impl GatewayBinding { /// Builds a binding around a client carrying test-specific timeouts. #[must_use] pub fn from_client(client: GatewayClient) -> Self { - let model_client = model_client(&client.base_url, &client.api_key); let base_url = client.base_url.clone(); let api_key = client.api_key.clone(); let snapshot = Arc::new(GatewaySnapshot { client, base_url, api_key, - model_client, generation: 0, identity: None, }); @@ -341,36 +328,14 @@ fn build_snapshot( ) -> Result { let client = GatewayClient::new(base_url, api_key)?; let base_url = client.base_url().to_owned(); - let model_client = model_client(&base_url, api_key); Ok(GatewaySnapshot { client, base_url, api_key: api_key.to_owned(), - model_client, generation, identity, }) } -/// Builds the agent model client carried in a Gateway snapshot. -pub fn model_client(base_url: &str, api_key: &str) -> Option { - let key = match SecretString::new(api_key) { - Ok(key) => key, - Err(error) => { - tracing::warn!(%error, "agent sessions disabled: gateway API key unusable"); - return None; - } - }; - let root = format!("{}/v1", base_url.trim_end_matches('/')); - let endpoint = match GatewayEndpoint::new(&root) { - Ok(endpoint) => endpoint, - Err(error) => { - tracing::warn!(%error, "agent sessions disabled: gateway URL unusable"); - return None; - } - }; - Some(ModelClient::new(endpoint, key)) -} - #[cfg(test)] mod tests; diff --git a/crates/workshop-gateway/src/gateway_binding/tests.rs b/crates/workshop-gateway/src/gateway_binding/tests.rs index 3ff4a2b50..add9fd39d 100644 --- a/crates/workshop-gateway/src/gateway_binding/tests.rs +++ b/crates/workshop-gateway/src/gateway_binding/tests.rs @@ -40,11 +40,6 @@ fn capability_replacement_publishes_one_coherent_snapshot() { assert_eq!(new.client().api_key, "new-key"); assert_eq!(new.base_url(), format!("http://127.0.0.1:{port}")); assert_eq!(new.api_key(), "new-key"); - assert!( - format!("{:?}", new.model_client().expect("the model client builds")) - .contains(&format!("http://127.0.0.1:{port}/v1")), - "the model client carries the same endpoint" - ); let identity = new .identity .as_ref() diff --git a/crates/workshop-gateway/src/gateway_binding/tests/atomic.rs b/crates/workshop-gateway/src/gateway_binding/tests/atomic.rs index 08c757e6e..416d51a67 100644 --- a/crates/workshop-gateway/src/gateway_binding/tests/atomic.rs +++ b/crates/workshop-gateway/src/gateway_binding/tests/atomic.rs @@ -31,20 +31,12 @@ fn synchronized_reads_never_observe_a_torn_replacement_snapshot() { assert_eq!(snapshot.base_url(), "http://127.0.0.1:54375"); assert_eq!(snapshot.api_key(), "old-key"); assert!(snapshot.identity.is_none()); - assert!( - format!("{:?}", snapshot.model_client().expect("old model client")) - .contains("http://127.0.0.1:54375/v1") - ); } else { assert_eq!(snapshot.client().base_url, reader_url); assert_eq!(snapshot.client().api_key, "new-key"); assert_eq!(snapshot.base_url(), reader_url); assert_eq!(snapshot.api_key(), "new-key"); assert_eq!(snapshot.identity.as_ref(), Some(&reader_validated)); - assert!( - format!("{:?}", snapshot.model_client().expect("new model client")) - .contains(&format!("{reader_url}/v1")) - ); } if snapshot.generation() >= target_generation { break; diff --git a/crates/workshop-gateway/src/gateway_progress.rs b/crates/workshop-gateway/src/gateway_progress.rs index 60f506b17..d6fcc40d7 100644 --- a/crates/workshop-gateway/src/gateway_progress.rs +++ b/crates/workshop-gateway/src/gateway_progress.rs @@ -23,7 +23,6 @@ use std::time::Duration; use futures_util::StreamExt; use tokio::sync::oneshot; -use promptforge_model_client::model::subscribe_progress; use shared_progress::{EventState, OperationId, ProgressHub, RemoteOperation}; use crate::gateway_binding::GatewayBinding; @@ -126,7 +125,7 @@ async fn run( } continue; } - result = subscribe_progress(snapshot.base_url(), snapshot.api_key()) => match result { + result = snapshot.client().subscribe_progress() => match result { Ok(stream) => stream, Err(error) => { tracing::warn!(%error, "gateway progress subscription failed"); diff --git a/crates/workshop-gateway/src/lib.rs b/crates/workshop-gateway/src/lib.rs index 98679ecad..2b5a52a7e 100644 --- a/crates/workshop-gateway/src/lib.rs +++ b/crates/workshop-gateway/src/lib.rs @@ -28,8 +28,8 @@ pub mod resolve; pub mod test_gateway; pub use gateway::{ - CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, SsePayloadStream, - SwitchEvent, SwitchEventStream, SwitchResponse, switch_events, + CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, ProgressEventStream, + SsePayloadStream, SwitchEvent, SwitchEventStream, SwitchResponse, switch_events, }; pub use gateway_binding::{ GatewayBinding, GatewayPublicationError, GatewaySnapshot, GatewayUpdater, diff --git a/crates/workshop-gateway/src/observer.rs b/crates/workshop-gateway/src/observer.rs index 63944dc23..dcfdf5ed2 100644 --- a/crates/workshop-gateway/src/observer.rs +++ b/crates/workshop-gateway/src/observer.rs @@ -7,11 +7,11 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; -use promptforge_core_support::events::{ +use serde::{Deserialize, Serialize}; +use shared_promptforge_api::events::{ CallMetrics, EventLog, RuntimeEvent, RuntimeEventKind, ToolCallEvent, }; -use promptforge_core_support::observe::{Observation, Observer}; -use serde::{Deserialize, Serialize}; +use shared_promptforge_api::observe::{Observation, Observer}; use tokio::sync::broadcast; /// The `format` field of the header line that opens every persisted log. @@ -119,8 +119,8 @@ impl WorkshopObserver { /// /// # Examples /// ``` - /// use promptforge_core_support::events::EventLog; - /// use promptforge_core_support::observe::Observer; + /// use shared_promptforge_api::events::EventLog; + /// use shared_promptforge_api::observe::Observer; /// use workshop_gateway::WorkshopObserver; /// /// let log = WorkshopObserver::new(None)?; @@ -151,8 +151,8 @@ impl WorkshopObserver { /// /// # Examples /// ``` - /// use promptforge_core_support::events::EventLog; - /// use promptforge_core_support::observe::Observer; + /// use shared_promptforge_api::events::EventLog; + /// use shared_promptforge_api::observe::Observer; /// use workshop_gateway::WorkshopObserver; /// /// let dir = tempfile::TempDir::new()?; @@ -188,7 +188,7 @@ impl WorkshopObserver { /// /// # Examples /// ``` - /// use promptforge_core_support::observe::Observer; + /// use shared_promptforge_api::observe::Observer; /// use workshop_gateway::WorkshopObserver; /// /// let log = WorkshopObserver::new(None)?; diff --git a/crates/workshop-gateway/src/observer/tests.rs b/crates/workshop-gateway/src/observer/tests.rs index fa76688ae..9feb81ccb 100644 --- a/crates/workshop-gateway/src/observer/tests.rs +++ b/crates/workshop-gateway/src/observer/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use promptforge_core_support::events::{ClientTiming, LlamaTimings, Usage, VllmMetrics}; use serde_json::json; +use shared_promptforge_api::events::{ClientTiming, LlamaTimings, Usage, VllmMetrics}; use super::*; diff --git a/crates/workshop-protocol/Cargo.toml b/crates/workshop-protocol/Cargo.toml index 16e17c551..96b0dc4c5 100644 --- a/crates/workshop-protocol/Cargo.toml +++ b/crates/workshop-protocol/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true description = "Workshop wire protocol: every JSON frame exchanged over the workshop sockets, typed in one place, with zero I/O" [dependencies] -promptforge-core-support.workspace = true +shared-promptforge-api.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/workshop-protocol/src/agent.rs b/crates/workshop-protocol/src/agent.rs index 15b1f9b15..6af01f83c 100644 --- a/crates/workshop-protocol/src/agent.rs +++ b/crates/workshop-protocol/src/agent.rs @@ -75,7 +75,7 @@ pub struct AgentEventFrame { #[serde(skip_serializing_if = "Option::is_none")] reply: Option, /// The logged entry, in its persisted vocabulary shape. - event: promptforge_core_support::events::RuntimeEvent, + event: shared_promptforge_api::events::RuntimeEvent, } impl AgentEventFrame { @@ -84,7 +84,7 @@ impl AgentEventFrame { pub fn new( index: u64, reply: Option, - event: promptforge_core_support::events::RuntimeEvent, + event: shared_promptforge_api::events::RuntimeEvent, ) -> Self { Self { kind: "agent_event", diff --git a/crates/workshop-protocol/tests/it/fixture.rs b/crates/workshop-protocol/tests/it/fixture.rs index f6e95ed25..8479b8ab0 100644 --- a/crates/workshop-protocol/tests/it/fixture.rs +++ b/crates/workshop-protocol/tests/it/fixture.rs @@ -20,8 +20,8 @@ fn agent_fixture() -> serde_json::Value { } /// The fixture's `agent_event_minimal` entry as the vocabulary type. -fn minimal_fixture_event() -> promptforge_core_support::events::RuntimeEvent { - use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; +fn minimal_fixture_event() -> shared_promptforge_api::events::RuntimeEvent { + use shared_promptforge_api::events::{RuntimeEvent, RuntimeEventKind}; RuntimeEvent { kind: RuntimeEventKind::UserInput, section: "chat".to_owned(), @@ -38,8 +38,8 @@ fn minimal_fixture_event() -> promptforge_core_support::events::RuntimeEvent { /// The fixture's `agent_event_stamped` entry as the vocabulary type, /// every metrics section populated. -fn stamped_fixture_event() -> promptforge_core_support::events::RuntimeEvent { - use promptforge_core_support::events::{ +fn stamped_fixture_event() -> shared_promptforge_api::events::RuntimeEvent { + use shared_promptforge_api::events::{ CallMetrics, ClientTiming, LlamaTimings, RuntimeEvent, RuntimeEventKind, Usage, VllmMetrics, }; RuntimeEvent { diff --git a/crates/workshop-protocol/tests/it/frames.rs b/crates/workshop-protocol/tests/it/frames.rs index 02c95a586..540e72910 100644 --- a/crates/workshop-protocol/tests/it/frames.rs +++ b/crates/workshop-protocol/tests/it/frames.rs @@ -135,7 +135,7 @@ fn an_agent_session_frame_serializes_its_id_and_agent() { #[test] fn an_agent_event_frame_carries_its_log_index_and_optional_reply_id() { - use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; + use shared_promptforge_api::events::{RuntimeEvent, RuntimeEventKind}; let event = RuntimeEvent { kind: RuntimeEventKind::UserInput, section: "chat".to_owned(), diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index fbd45ffee..8b8ff3c26 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -63,13 +63,8 @@ workshop-server = { path = ".", features = ["test-fixtures"] } shared-sidecar = { workspace = true, features = ["test-fixtures"] } # The socket integration tests drive agent programs on the runtime engine # directly, so the engine crates are test-only dependencies of the shell. -promptforge-agent.workspace = true -promptforge-core.workspace = true -promptforge-core-support.workspace = true -promptforge-model-client.workspace = true -promptforge-tool-picker.workspace = true -promptforge-tools.workspace = true -promptforge-vfs.workspace = true +promptforge-api.workspace = true +shared-promptforge-api.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["test-util"] } tower.workspace = true diff --git a/crates/workshop-server/README.md b/crates/workshop-server/README.md index 2306e7cac..3456ac7eb 100644 --- a/crates/workshop-server/README.md +++ b/crates/workshop-server/README.md @@ -2,7 +2,7 @@ [![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) -The PromptForge Workshop HTTP server. It serves a local UI and API on loopback: agent sessions (chat runs through `.lua` agent programs over `promptforge-agent`), an OpenAI-shaped model catalog passthrough in front of a PromptForge gateway, workspace APIs, and a same-origin payload-opaque relay to Gateway Realtime transcription. The desktop shell (`workshop`) embeds it in-process; run standalone it is the browser-tab frame. +The PromptForge Workshop HTTP server. It serves a local UI and API on loopback: agent sessions (Markdown agent prompts on the unified `promptforge-api` runtime), an OpenAI-shaped model catalog passthrough in front of a PromptForge gateway, workspace APIs, and a same-origin payload-opaque relay to Gateway Realtime transcription. The desktop shell (`workshop`) embeds it in-process; run standalone it is the browser-tab frame. ## Quick start @@ -43,7 +43,7 @@ Every field of `workshop.toml`: | `server.bind` | `127.0.0.1:7910` | Address the workshop server binds to | | `server.open_browser` | `false` | When true, the server binary opens the system browser at its address once serving; the desktop shell ignores it | | `server.state_dir` | the config file's directory | Directory holding the server's persistent state: agent session event logs live under `state_dir/sessions/`, and the per-profile model memory is written here | -| `agents.path` | `agents/` beside the config file | Directory whose `.lua` files are launchable agent programs alongside the embedded built-in `chat` agent; a directory `chat.lua` shadows the embedded source, and a missing directory offers exactly the built-in | +| `agents.path` | `agents/` beside the config file | Directory whose `.md` files are launchable agent prompts alongside the embedded built-in `chat` agent; a directory `chat.md` shadows the embedded source, and a missing directory offers exactly the built-in | ## Routes @@ -135,11 +135,11 @@ The variables: ## Agent input waits -`WaitRegistry` holds an agent session's unresolved user-input waits behind single-use cryptographic tokens, retained across socket loss and resent on reconnect. `UserInputTool` is the Workshop's `user_input` tool - never advertised to a model - whose `call()` registers a wait, pushes the durable `input_required` frame itself, and suspends until `deliver_input_response` fires `on_user_input` byte-exact and completes the wait; its output is trusted, structured JSON (`text` byte-exact, `images` present and empty). A drop guard turns every dying wait into a durable `input_cancelled` frame, so a cancelled turn never leaks a wait or leaves a stale prompt. +`WaitRegistry` holds an agent session's unresolved user-input waits behind single-use cryptographic tokens, retained across socket loss and resent on reconnect. `SessionInputBroker` is the session's input broker behind the script-side `user_input()` - never advertised to a model - which registers a wait, pushes the durable `input_required` frame itself, and suspends until `deliver_input_response` fires `on_user_input` byte-exact and completes the wait. A drop guard turns every dying wait into a durable `input_cancelled` frame, so a cancelled turn never leaks a wait or leaves a stale prompt. ## Agent sessions -`AgentSessions` (reached through `AppState::agents`) is the registry behind `GET /agents/ws`: it discovers `.lua` agent programs from `agents.path` and always offers the embedded built-in `chat` agent, a Markdown prompt running on the unified `promptforge_core` runtime (a directory `chat.lua` shadows it). A directory agent launches as a session running `promptforge_agent::run_agent`; the built-in launches as a `promptforge_core::run` prompt execution. Every session carries the Workshop's input broker behind `user_input`, a persisting `WorkshopObserver` event log at `state_dir/sessions/.jsonl`, a model catalog built from the retained gateway catalog, and a `ui()` snapshot serving the selected model and the first granted workspace root. Sessions survive socket disconnect: sockets attach and detach, a reconnect replays the persisted log (every durable frame carries its log index) and re-announces unresolved waits. Live deltas ride a dedicated ephemeral channel, each stamped with the reply id of the durable event that will supersede it. Turn-cancel fires the session's retained cancel handle and relaunches the program over the retained event log - a stop reason, never an error - while `AgentSessions::close` ends a session for good. +`AgentSessions` (reached through `AppState::agents`) is the registry behind `GET /agents/ws`: it discovers `.md` agent prompts from `agents.path` and always offers the embedded built-in `chat` agent, a Markdown prompt running on the unified `promptforge_api` runtime (a directory `chat.md` shadows it). Every agent launches as a `promptforge_api::run` prompt execution. Every session carries the Workshop's input broker behind `user_input`, a persisting `WorkshopObserver` event log at `state_dir/sessions/.jsonl`, a model catalog built from the retained gateway catalog, and a `ui()` snapshot serving the selected model and the first granted workspace root. Sessions survive socket disconnect: sockets attach and detach, a reconnect replays the persisted log (every durable frame carries its log index) and re-announces unresolved waits. Live deltas ride a dedicated ephemeral channel, each stamped with the reply id of the durable event that will supersede it. Turn-cancel fires the session's retained cancel handle and relaunches the program over the retained event log - a stop reason, never an error - while `AgentSessions::close` ends a session for good. ## Minimum Rust Version diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index 600eca028..57a8d082b 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -3,7 +3,7 @@ //! Holds the `workshop.toml` configuration, the PromptForge gateway client, //! and the axum router so `src/main.rs` stays a thin shell. Start at //! [`Config::load`] for configuration, [`WorkshopObserver`] for the run -//! event log, [`WaitRegistry`] and [`UserInputTool`] for agent input +//! event log, [`WaitRegistry`] and [`SessionInputBroker`] for agent input //! waits, [`AgentSessions`] for the agent-session registry behind //! `/agents/ws`, and [`router`] for the HTTP API; [`spawn`] runs the whole //! server in-process on its own thread for embedding binaries. @@ -36,7 +36,7 @@ mod serve; // The extracted subsystem crates, aliased at their pre-decomposition // module paths so the shell's internals read as they did before the -// split. The tier graph is enforced by `cargo test -p xtask`. +// split. The tier graph is enforced by `cargo test -p build-xtask`. pub use workshop_gateway::{ gateway, gateway_binding, gateway_progress, heartbeat, observer, resolve, }; @@ -77,8 +77,7 @@ pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; pub use serve::{ServerHandle, SpawnError, Termination, spawn}; pub use workshop_protocol::{Activity, InputFrame, InputResponse}; pub use workshop_sessions::{ - AgentSessions, SessionInputBroker, UserInputTool, WaitError, WaitRegistry, - deliver_input_response, + AgentSessions, SessionInputBroker, WaitError, WaitRegistry, deliver_input_response, }; pub use workshop_support::{ AgentsConfig, Config, ConfigError, DEFAULT_CONFIG_PATH, GatewayConfig, ServerConfig, diff --git a/crates/workshop-server/tests/it/agents.rs b/crates/workshop-server/tests/it/agents.rs index 9d7824d84..ea6e59384 100644 --- a/crates/workshop-server/tests/it/agents.rs +++ b/crates/workshop-server/tests/it/agents.rs @@ -32,15 +32,33 @@ use workshop_server::{ use crate::common::{JsonSocket, spawn_gateway}; -/// The echo agent: loops on `user_input`, runs one chat round per input, -/// and returns on `quit`. -const ECHO_AGENT: &str = r" -models.use('test-model') +/// The echo agent: a Markdown prompt on the unified runtime that loops +/// on `user_input`, runs one chat round per input against the fixture's +/// `test-model`, and returns on `quit`. +const ECHO_MD: &str = r"--- +name: echo +description: The echo test agent on the unified runtime. +promptforge: 0 +--- + +# Echo + +## Conversation + +```lua +local history = messages.new() while true do - local input = tools.call('user_input', {}) - if input.text == 'quit' then return end - models.chat({ { role = 'user', content = input.text } }) + local text, available = user_input() + if not available then + return + end + if text == 'quit' then + return + end + history:user(text) + models.loop(models.get('test-model'), history) end +``` "; /// Streams `echo:` as an SSE completion: a reasoning @@ -146,7 +164,7 @@ async fn spawn_agent_server_for_gateway(base_url: String) -> (String, tempfile:: let dir = tempfile::TempDir::new().expect("tempdir"); let agents_dir = dir.path().join("agents"); std::fs::create_dir(&agents_dir).expect("the agents directory creates"); - std::fs::write(agents_dir.join("echo.lua"), ECHO_AGENT).expect("the echo agent writes"); + std::fs::write(agents_dir.join("echo.md"), ECHO_MD).expect("the echo agent writes"); let config = Config { gateway: GatewayConfig { base_url, diff --git a/crates/workshop-server/tests/it/agents/lifecycle.rs b/crates/workshop-server/tests/it/agents/lifecycle.rs index 71074c668..fd5782a71 100644 --- a/crates/workshop-server/tests/it/agents/lifecycle.rs +++ b/crates/workshop-server/tests/it/agents/lifecycle.rs @@ -30,7 +30,7 @@ async fn two_sessions_do_not_cross_talk() { .collect(); assert_eq!( indices, - [0, 1, 2, 3], + [0, 1, 2], "each session's log is its own: no foreign entries shift the indices" ); } @@ -132,8 +132,22 @@ async fn a_terminal_agent_failure_reaches_the_socket_as_an_error_frame() { // An agent that dies after its first input, so the socket is attached // and subscribed long before the failure fires. std::fs::write( - dir.path().join("agents").join("boom.lua"), - "tools.call('user_input', {})\nerror('kaboom')", + dir.path().join("agents").join("boom.md"), + r"--- +name: boom +description: The terminally failing test agent. +promptforge: 0 +--- + +# Boom + +## Conversation + +```lua +user_input() +error('kaboom') +``` +", ) .expect("the boom agent writes"); let mut socket = JsonSocket::connect(&format!("{base}/agents/ws")).await; diff --git a/crates/workshop-server/tests/it/agents/turns.rs b/crates/workshop-server/tests/it/agents/turns.rs index 9d930834b..7ccd4d880 100644 --- a/crates/workshop-server/tests/it/agents/turns.rs +++ b/crates/workshop-server/tests/it/agents/turns.rs @@ -40,14 +40,10 @@ async fn a_full_turn_streams_deltas_and_indexed_events_sharing_the_reply_id() { .collect(); assert_eq!( kinds, - [ - "user_message", - "tool_call_update", - "agent_thought", - "agent_message" - ], - "the durable record of one turn: input, the user_input tool's own \ - result, thinking, reply" + ["user_message", "agent_thought", "agent_message"], + "the durable record of one turn: input, thinking, reply - the \ + direct user_input call is not a tool call, so no tool_call_update \ + exists" ); let indices: Vec = turn .events @@ -56,7 +52,7 @@ async fn a_full_turn_streams_deltas_and_indexed_events_sharing_the_reply_id() { .collect(); assert_eq!( indices, - [0, 1, 2, 3], + [0, 1, 2], "durable frames carry monotonically increasing log indices" ); assert_eq!(turn.events[0]["event"]["content"], "ping"); @@ -64,17 +60,13 @@ async fn a_full_turn_streams_deltas_and_indexed_events_sharing_the_reply_id() { turn.events[0].get("reply").is_none(), "a user_message settles no deltas and carries no reply id" ); - assert!( - turn.events[1].get("reply").is_none(), - "a tool result settles no deltas and carries no reply id" - ); assert_eq!( - turn.events[2]["reply"], 0, + turn.events[1]["reply"], 0, "the thinking event supersedes the reasoning deltas of its round" ); - assert_eq!(turn.events[3]["event"]["content"], "echo:ping"); + assert_eq!(turn.events[2]["event"]["content"], "echo:ping"); assert_eq!( - turn.events[3]["reply"], 0, + turn.events[2]["reply"], 0, "deltas and the completed reply share the superseding event id" ); @@ -93,8 +85,8 @@ async fn a_full_turn_streams_deltas_and_indexed_events_sharing_the_reply_id() { .iter() .filter_map(|event| event["index"].as_u64()) .collect(); - assert_eq!(indices, [4, 5, 6, 7], "indices continue across turns"); - assert_eq!(turn.events[3]["reply"], 1); + assert_eq!(indices, [3, 4, 5], "indices continue across turns"); + assert_eq!(turn.events[2]["reply"], 1); socket.close().await; } diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index fe9bcc58d..0da883245 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -27,18 +27,14 @@ use futures_util::StreamExt as _; use serde_json::json; use tokio::sync::broadcast; -use promptforge_agent::AgentError; -use promptforge_core::execute::RunErrorKind; -use promptforge_core::{Prompt, ResolutionContext, RunConfig}; -use promptforge_core_support::cancel::CancelHandle; -use promptforge_core_support::events::{EventLog as _, RuntimeEventKind}; -use promptforge_core_support::observe::Observer; -use promptforge_model_client::client::{ - GatewayClient as ModelClient, GatewayEndpoint, SecretString, -}; -use promptforge_model_client::model::ModelCatalog; -use promptforge_tool_picker::{Catalog as PickerCatalog, Config as PickerConfig, ToolPicker}; -use promptforge_tools::ToolCatalog; +use promptforge_api::client::{GatewayClient as ModelClient, GatewayEndpoint, SecretString}; +use promptforge_api::execute::RunErrorKind; +use promptforge_api::{Prompt, ResolutionContext, RunConfig}; +use shared_promptforge_api::cancel::CancelHandle; +use shared_promptforge_api::events::{EventLog as _, RuntimeEventKind}; +use shared_promptforge_api::models::ModelCatalog; +use shared_promptforge_api::observe::Observer; +use shared_promptforge_api::tools::ToolCatalog; use workshop_server::fixtures::{gateway_updater, replace_gateway, state_with_gateway}; use workshop_server::{ AgentsConfig, AppState, Config, GatewayConfig, InputFrame, InputResponse, ResolvedGateway, @@ -51,6 +47,20 @@ use crate::common::{JsonSocket, spawn_gateway}; /// The embedded built-in chat prompt, exactly what a `chat` launch runs. const CHAT_MD: &str = include_str!("../../../workshop-sessions/agents/chat.md"); +/// The relaunch harness's terminal outcome, mirroring the supervisor's +/// `AgentRunError`: cancellation maps to `Interrupted`, and every other +/// run failure carries its rendered message. +#[derive(Debug)] +enum AgentError { + /// The run's cancel handle fired. + Interrupted, + /// The prompt run failed. + Program { + /// The failure's rendered message. + message: String, + }, +} + /// Every completion request body the gate mock received, in arrival /// order: the gate's proof of exactly what the model was shown. type CapturedRequests = Arc>>; @@ -334,8 +344,6 @@ fn spawn_restored_chat( GatewayEndpoint::new(&format!("{gateway_url}/v1")).expect("the mock endpoint parses"), SecretString::new("test-key").expect("the test key is non-empty"), ); - let picker = ToolPicker::build(PickerCatalog::new(Vec::new()), PickerConfig::default()) - .expect("the empty picker builds"); let cancel = CancelHandle::new(); let observer: Arc = restored.clone(); let config = RunConfig::new(session.to_owned()) @@ -352,13 +360,11 @@ fn spawn_restored_chat( let prompt = Prompt::parse(CHAT_MD, &execution, observer.as_ref()) .expect("the embedded chat prompt parses"); let models = ModelCatalog::empty(); - let tools = ToolCatalog::new(&[]).expect("an empty tool catalog is valid"); - let store = promptforge_vfs::empty(); - promptforge_core::run( + let tools = ToolCatalog::default(); + promptforge_api::run( &prompt, "", - ResolutionContext::new(&picker, &models, &tools), - &store, + ResolutionContext::new(None, &models, &tools), config, ) .await @@ -371,7 +377,6 @@ fn spawn_restored_chat( } Err(error) => Err(AgentError::Program { message: error.to_string(), - source: Some(Box::new(error)), }), } }); diff --git a/crates/workshop-server/tests/it/chat_gate/recovery.rs b/crates/workshop-server/tests/it/chat_gate/recovery.rs index 9c445e43d..9e4fe4025 100644 --- a/crates/workshop-server/tests/it/chat_gate/recovery.rs +++ b/crates/workshop-server/tests/it/chat_gate/recovery.rs @@ -153,10 +153,13 @@ async fn gate_restart_reloads_the_jsonl_and_resumes_waiting_for_input() { // Teardown: the loop is back on user_input; cancellation ends it. relaunch.cancel.cancel(); let result = relaunch.run.await.expect("the relaunched run joins"); - assert!( - matches!(result, Err(AgentError::Interrupted)), - "cancellation ends the relaunched run cleanly, got {result:?}" - ); + match result { + Err(AgentError::Interrupted) => {} + Err(AgentError::Program { message }) => { + panic!("the relaunched run failed instead of interrupting: {message}"); + } + Ok(()) => panic!("cancellation ends the relaunched run cleanly, got Ok(())"), + } } /// GATE 6 - error survival. Current-chat behavior: a failed completion diff --git a/crates/workshop-server/tests/it/observer.rs b/crates/workshop-server/tests/it/observer.rs index 65abb2c93..201176570 100644 --- a/crates/workshop-server/tests/it/observer.rs +++ b/crates/workshop-server/tests/it/observer.rs @@ -7,7 +7,7 @@ use std::path::Path; -use promptforge_core_support::events::{ +use shared_promptforge_api::events::{ CallMetrics, ClientTiming, EventLog, LlamaTimings, RuntimeEvent, RuntimeEventKind, Usage, VllmMetrics, }; diff --git a/crates/workshop-server/ui/src/services/protocol.ts b/crates/workshop-server/ui/src/services/protocol.ts index c1f15acd6..63df8c88b 100644 --- a/crates/workshop-server/ui/src/services/protocol.ts +++ b/crates/workshop-server/ui/src/services/protocol.ts @@ -61,7 +61,7 @@ export interface WorkbenchFrame { /** * The kind of one runtime event, following the Agent Client Protocol * `sessionUpdate` names. Mirrors `RuntimeEventKind` in - * promptforge-core-support (src/events.rs), which is `#[non_exhaustive]`: + * shared-promptforge-api (src/events.rs), which is `#[non_exhaustive]`: * future kinds (`plan`, tool-status updates) may arrive as labels outside * this union, so renderers matching on kinds tolerate unknown labels * through a wildcard arm. @@ -120,7 +120,7 @@ export interface CallMetrics { /** * One durable record of something that happened during an agent run, - * mirroring `RuntimeEvent` in promptforge-core-support (src/events.rs). + * mirroring `RuntimeEvent` in shared-promptforge-api (src/events.rs). * `content` and every other free-text field is untrusted model-, tool-, or * user-authored data. Absent optional fields are omitted keys on the wire, * never `null`. diff --git a/crates/workshop-sessions/Cargo.toml b/crates/workshop-sessions/Cargo.toml index 6f183a76e..129e52bb0 100644 --- a/crates/workshop-sessions/Cargo.toml +++ b/crates/workshop-sessions/Cargo.toml @@ -16,13 +16,8 @@ test-fixtures = [] async-trait.workspace = true axum.workspace = true futures-util.workspace = true -promptforge-agent.workspace = true -promptforge-core.workspace = true -promptforge-core-support.workspace = true -promptforge-model-client.workspace = true -promptforge-tool-picker.workspace = true -promptforge-tools.workspace = true -promptforge-vfs.workspace = true +promptforge-api.workspace = true +shared-promptforge-api.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/workshop-sessions/README.md b/crates/workshop-sessions/README.md new file mode 100644 index 000000000..13665e513 --- /dev/null +++ b/crates/workshop-sessions/README.md @@ -0,0 +1,23 @@ +# workshop-sessions + +The PromptForge Workshop's agent session subsystem. It discovers `.md` agent prompts from the configured agents directory, launches each one as a `promptforge-api` prompt execution on the unified document runtime, and carries the session around the run: the input broker behind `user_input()`, the `ui()` host-state snapshot, streaming deltas, cancellation, and the persisting event log. + +## Agents + +An agent is one `.md` PromptForge prompt file. Discovery lists the `.md` file stems under `agents.path` (the default is `agents/` beside the config file), sorted, plus the built-in `chat`: a Markdown prompt embedded at compile time from `agents/chat.md`, so a fresh install always has a working chat with no agents directory at all. A directory file named `chat.md` shadows the embedded source, and an existing `chat.md` that cannot be read surfaces its error instead of silently serving the built-in. A missing or unreadable directory offers exactly the built-in. + +Discovery reads the directory per request, so a newly added agent file shows up in the agent list on the next connect, without a restart. Discovery yields bare file stems only, and launch resolves names through the discovered list: a client-sent name never reaches the filesystem unless it is the stem of a real `.md` file in the configured directory. Launching parses the file with `Prompt::parse` and runs it with `promptforge_api::run`. + +## Sessions + +Every session carries the Workshop's input broker behind the script-side `user_input()` - never advertised to a model - a `ui()` snapshot serving the selected model and the first granted workspace root, a model catalog built from the retained gateway catalog, and an observer-backed event log persisted as one JSONL file per session under the state directory. Live deltas ride a dedicated ephemeral channel, each stamped with the reply id of the durable event that will supersede it. + +A host-fired cancel interrupts the run, even while a host call is suspended, and a relaunch reruns the program over the retained event log - a stop reason, never an error. Closing the session ends the run for good; the saved transcript stays on disk. + +## Minimum Rust Version + +Rust 1.89 or later. + +## License + +Licensed under the [Boost Software License 1.0](../../LICENSE). diff --git a/crates/workshop-sessions/src/agents.rs b/crates/workshop-sessions/src/agents.rs index cb7eb1854..f81e47b92 100644 --- a/crates/workshop-sessions/src/agents.rs +++ b/crates/workshop-sessions/src/agents.rs @@ -1,4 +1,4 @@ -//! Agent sessions: discovery of `.lua` agent programs, the +//! Agent sessions: discovery of `.md` agent programs, the //! [`AgentSessions`] registry, and each session's run lifecycle. //! //! A session owns one running agent: its persisting event log @@ -7,7 +7,7 @@ //! [`crate::input::WaitRegistry`] and `user_input` tool, its dedicated //! delta broadcast (deltas never enter the event log), and the retained //! cancel handle behind turn-cancel. The supervisor task relaunches -//! `run_agent` over the retained event log after a turn-cancel - +//! the agent run over the retained event log after a turn-cancel - //! cancellation is a stop reason, never an error - and ends the session //! when the program returns or fails. //! @@ -38,6 +38,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use tokio::sync::{broadcast, mpsc}; +use promptforge_api::client::{GatewayClient, GatewayEndpoint, SecretString}; use workshop_gateway::{GatewayBinding, WorkshopObserver}; use workshop_menu::{CatalogBus, MenuBus}; use workshop_registry::{Push, Registry}; @@ -48,7 +49,7 @@ use crate::input::WaitRegistry; use self::lifecycle::RunLifecycle; pub(crate) use session::{AgentDelta, AgentSession, AgentSource, SessionObserver}; -pub(crate) use session::{build_model_catalog, delta_stamp, reply_stamp, ui_provider}; +pub(crate) use session::{delta_stamp, reply_stamp, ui_provider}; /// Capacity of a session's delta broadcast. Deltas are ephemeral: a /// receiver that lags loses chunks, and the completed-reply event is the @@ -69,12 +70,11 @@ pub(crate) const ERROR_CAPACITY: usize = 8; /// The committed built-in chat agent, embedded at compile time - the same /// shipped-asset pattern as the SPA `dist/` - so a fresh install has a /// working chat with no agents directory at all. The built-in is a -/// Markdown prompt on the unified runtime; the standalone `chat.lua` -/// program is retired. +/// Markdown prompt on the unified runtime. pub(crate) const BUILTIN_CHAT_SOURCE: &str = include_str!("../agents/chat.md"); /// The built-in default agent's name: discovery always offers it, and a -/// directory file named `chat.lua` shadows the embedded source. +/// directory file named `chat.md` shadows the embedded source. const BUILTIN_CHAT_NAME: &str = "chat"; /// The shared handles a session's lifecycle reports flow through, @@ -155,7 +155,7 @@ pub struct AgentSessions { /// The shared registry state behind the cloneable handle. struct Inner { - /// Directory whose `.lua` files are the launchable agents. + /// Directory whose `.md` files are the launchable agents. agents_dir: PathBuf, /// Where session event JSONLs persist (`state_dir/sessions`). sessions_dir: PathBuf, @@ -201,11 +201,11 @@ impl AgentSessions { } } - /// The launchable agent names: the `.lua` file stems under the + /// The launchable agent names: the `.md` file stems under the /// configured agents directory plus the built-in `chat`, sorted. The /// built-in is always offered - a missing or unreadable directory /// still lists it, so a fresh install always has a working chat - and - /// a directory file named `chat.lua` shadows the embedded source + /// a directory file named `chat.md` shadows the embedded source /// rather than listing twice. #[must_use] pub fn discover(&self) -> Vec { @@ -227,7 +227,7 @@ impl AgentSessions { pub(crate) fn launch(&self, name: &str) -> Result, LaunchRefusal> { // Resolving through the discovered list is the trust boundary: a // client-sent name never reaches the filesystem unless it is the - // bare stem of a real `.lua` file in the configured directory. + // bare stem of a real `.md` file in the configured directory. if !self.discover().iter().any(|agent| agent == name) { return Err(LaunchRefusal::UnknownAgent { name: name.to_owned(), @@ -238,7 +238,8 @@ impl AgentSessions { // chat, but an agent run would fail its first model round - or // silently resolve a different gateway from the environment - so // the launch refuses instead. - if self.inner.gateway.snapshot().model_client().is_none() { + let snapshot = self.inner.gateway.snapshot(); + if agent_client(snapshot.base_url(), snapshot.api_key()).is_none() { return Err(LaunchRefusal::GatewayUnusable); } let source = agent_source(&self.inner.agents_dir, name) @@ -362,9 +363,33 @@ pub(crate) enum LaunchRefusal { }, } -/// Lists the launchable agent names: the `.lua` file stems under `dir` +/// Builds the agent completion client from one Gateway snapshot's base +/// URL and bearer, through the `promptforge-api` client re-exports. +/// `None` - reported as [`LaunchRefusal::GatewayUnusable`] at launch and +/// as a failed relaunch by the supervisor - when the key or URL cannot +/// build a client. +pub(crate) fn agent_client(base_url: &str, api_key: &str) -> Option { + let key = match SecretString::new(api_key) { + Ok(key) => key, + Err(error) => { + tracing::warn!(%error, "agent sessions disabled: gateway API key unusable"); + return None; + } + }; + let root = format!("{}/v1", base_url.trim_end_matches('/')); + let endpoint = match GatewayEndpoint::new(&root) { + Ok(endpoint) => endpoint, + Err(error) => { + tracing::warn!(%error, "agent sessions disabled: gateway URL unusable"); + return None; + } + }; + Some(GatewayClient::new(endpoint, key)) +} + +/// Lists the launchable agent names: the `.md` file stems under `dir` /// plus the built-in `chat`, sorted. A missing or unreadable directory -/// offers exactly the built-in, and a directory `chat.lua` lists once - +/// offers exactly the built-in, and a directory `chat.md` lists once - /// it shadows the embedded source instead of duplicating the name. fn discover_agents(dir: &Path) -> Vec { let mut names: Vec = std::fs::read_dir(dir) @@ -373,7 +398,7 @@ fn discover_agents(dir: &Path) -> Vec { .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| { - path.is_file() && path.extension().is_some_and(|extension| extension == "lua") + path.is_file() && path.extension().is_some_and(|extension| extension == "md") }) .filter_map(|path| { path.file_stem() @@ -389,15 +414,15 @@ fn discover_agents(dir: &Path) -> Vec { } /// Reads the agent's program source: the directory file when it exists - -/// a directory `chat.lua` shadows the built-in - else the embedded +/// a directory `chat.md` shadows the built-in - else the embedded /// built-in for the `chat` name alone. Launch resolved `name` through /// discovery already, so a missing file for any other name is a real /// filesystem race, surfaced as the error it is; so is an existing -/// `chat.lua` that cannot be read, because silently serving the built-in +/// `chat.md` that cannot be read, because silently serving the built-in /// would mask the operator's own file. fn agent_source(dir: &Path, name: &str) -> io::Result { - match std::fs::read_to_string(dir.join(format!("{name}.lua"))) { - Ok(source) => Ok(AgentSource::Lua(source)), + match std::fs::read_to_string(dir.join(format!("{name}.md"))) { + Ok(source) => Ok(AgentSource::Markdown(source)), Err(error) if name == BUILTIN_CHAT_NAME && error.kind() == io::ErrorKind::NotFound => { Ok(AgentSource::Markdown(BUILTIN_CHAT_SOURCE.to_owned())) } diff --git a/crates/workshop-sessions/src/agents/lifecycle.rs b/crates/workshop-sessions/src/agents/lifecycle.rs index f20b83224..96736bccf 100644 --- a/crates/workshop-sessions/src/agents/lifecycle.rs +++ b/crates/workshop-sessions/src/agents/lifecycle.rs @@ -2,7 +2,7 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; -use promptforge_core_support::cancel::CancelHandle; +use shared_promptforge_api::cancel::CancelHandle; use tokio::sync::mpsc; use super::supervisor::transition::{RunId, SupervisorEvent}; diff --git a/crates/workshop-sessions/src/agents/session.rs b/crates/workshop-sessions/src/agents/session.rs index 71d30d8ca..4c205a996 100644 --- a/crates/workshop-sessions/src/agents/session.rs +++ b/crates/workshop-sessions/src/agents/session.rs @@ -1,21 +1,19 @@ //! One running agent session: the state that outlives any socket, the -//! per-session observer `run_agent` reports through, and the launch-time -//! providers for deltas, the `ui()` snapshot, and the model catalog. +//! per-session observer the agent run reports through, and the +//! launch-time providers for deltas and the `ui()` snapshot. use std::fmt; -use std::num::NonZeroU32; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use promptforge_core_support::cancel::CancelHandle; -use promptforge_core_support::events::{CallMetrics, RuntimeEventKind, ToolCallEvent}; -use promptforge_core_support::observe::{Observation, Observer}; -use promptforge_model_client::client::StreamDelta; -use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; +use shared_promptforge_api::cancel::CancelHandle; +use shared_promptforge_api::events::{CallMetrics, RuntimeEventKind, ToolCallEvent}; +use shared_promptforge_api::observe::{Observation, Observer}; +use shared_promptforge_api::wire::StreamDelta; use tokio::sync::broadcast; use workshop_gateway::WorkshopObserver; -use workshop_menu::{MenuBus, is_chat_capable}; +use workshop_menu::MenuBus; use workshop_protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; use workshop_registry::{Push, Registry}; @@ -23,16 +21,11 @@ use super::lifecycle::RunLifecycle; use super::supervisor::transition::RunId; use crate::input::{WaitError, WaitRegistry}; -/// One agent's program source and the runtime that executes it. -/// -/// Directory agents are standalone Lua programs on the agent runtime; the -/// embedded built-in chat is a Markdown prompt on the unified runtime. -/// External Markdown-agent discovery stays deferred, so no directory file -/// ever lands in the Markdown arm. +/// One agent's program source: a Markdown prompt document on the +/// unified runtime. Directory agents and the embedded built-in chat are +/// both Markdown; the standalone Lua agent path is retired. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum AgentSource { - /// A standalone Lua agent program (the agent runtime). - Lua(String), /// A Markdown prompt document (the unified runtime). Markdown(String), } @@ -54,7 +47,7 @@ pub(crate) struct AgentDelta { pub(crate) struct AgentSession { /// The session's unguessable id, also its event JSONL's file stem. pub(crate) id: String, - /// The agent's name (its `.lua` file stem), every observer call's + /// The agent's name (its `.md` file stem), every observer call's /// `section` label. pub(crate) agent: String, /// The program source and its runtime, retained so turn-cancel can @@ -132,30 +125,16 @@ impl AgentSession { /// Durably accepts one input and resumes its wait after publishing /// acceptance ahead of the observation-to-completion boundary. /// - /// Recording is runtime-specific: a Lua agent's input is recorded - /// producer-side here (its relaunched program rebuilds history from - /// the event log), while a Markdown agent's unified runtime records - /// consumer-side when the suspended `user_input` resumes - recording - /// here too would double the event. + /// Recording is consumer-side: the unified runtime records the + /// operator's text when the suspended `user_input` resumes, so + /// recording here too would double the event. pub(crate) fn accept_input( &self, response: InputResponse, after_acceptance: impl FnOnce(), ) -> Result<(), WaitError> { let accepted_run = self.lifecycle.accept_input(); - let result = match &self.source { - AgentSource::Lua(_) => crate::input::deliver_input_response_before_completion( - self.log.as_ref(), - &self.waits, - &self.id, - &self.agent, - response, - after_acceptance, - ), - AgentSource::Markdown(_) => { - crate::input::complete_input_response(&self.waits, response, after_acceptance) - } - }; + let result = crate::input::complete_input_response(&self.waits, response, after_acceptance); if let (Err(_), Some(run)) = (&result, accepted_run) { self.lifecycle.settle_turn(run); } @@ -393,74 +372,3 @@ pub(crate) fn reply_stamp(kind: RuntimeEventKind, rounds_seen: &mut u64) -> Opti _ => None, } } - -/// Context window recorded for a catalog entry that does not carry one. -/// The window is catalog metadata (nothing on the completion wire reads -/// it), so a generous default keeps the model usable rather than -/// refusing it. -pub(super) const FALLBACK_CONTEXT: u32 = 8192; - -/// Builds the session's model catalog from the retained gateway catalog: -/// one descriptor per chat entry (an absent `kind` is a plain OpenAI -/// catalog and counts as chat), carrying the entry's description, -/// context window, and thinking mode where present. Entries that cannot -/// make a descriptor are skipped with a warning - a launch must not fail -/// because one catalog row is malformed. -pub(crate) fn build_model_catalog(models: Option>) -> ModelCatalog { - let Some(models) = models else { - return ModelCatalog::empty(); - }; - let mut descriptors: Vec = Vec::new(); - for entry in &models { - if !is_chat_capable(entry) { - continue; - } - let Some(id) = entry.get("id").and_then(serde_json::Value::as_str) else { - tracing::warn!("catalog entry without an id skipped for the agent model catalog"); - continue; - }; - let model_id = match ModelId::gateway(id) { - Ok(model_id) => model_id, - Err(error) => { - tracing::warn!(%error, id, "catalog entry skipped for the agent model catalog"); - continue; - } - }; - if descriptors - .iter() - .any(|descriptor| descriptor.id() == &model_id) - { - tracing::warn!( - id, - "duplicate catalog id skipped for the agent model catalog" - ); - continue; - } - let description = entry - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let context = entry - .get("context") - .and_then(serde_json::Value::as_u64) - .and_then(|context| u32::try_from(context).ok()) - .and_then(NonZeroU32::new) - .unwrap_or_else(|| NonZeroU32::new(FALLBACK_CONTEXT).unwrap_or(NonZeroU32::MIN)); - let thinking = entry - .get("thinking") - .and_then(|value| serde_json::from_value::(value.clone()).ok()) - .unwrap_or(ThinkingMode::Never); - descriptors.push(ModelDescriptor::new( - model_id, - description, - context, - thinking, - )); - } - // Duplicates were filtered above, so construction cannot refuse; an - // empty catalog is the honest degenerate outcome. - ModelCatalog::new(descriptors).unwrap_or_else(|error| { - tracing::warn!(%error, "agent model catalog degraded to empty"); - ModelCatalog::empty() - }) -} diff --git a/crates/workshop-sessions/src/agents/socket.rs b/crates/workshop-sessions/src/agents/socket.rs index 64ba6de22..4e8ea78cf 100644 --- a/crates/workshop-sessions/src/agents/socket.rs +++ b/crates/workshop-sessions/src/agents/socket.rs @@ -29,7 +29,7 @@ use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::HeaderMap; use axum::response::Response; -use promptforge_core_support::events::{EventLog as _, RuntimeEvent}; +use shared_promptforge_api::events::{EventLog as _, RuntimeEvent}; use tokio::sync::broadcast; use workshop_protocol::{ diff --git a/crates/workshop-sessions/src/agents/supervisor.rs b/crates/workshop-sessions/src/agents/supervisor.rs index 2a2484cd1..0ea2cddcd 100644 --- a/crates/workshop-sessions/src/agents/supervisor.rs +++ b/crates/workshop-sessions/src/agents/supervisor.rs @@ -2,13 +2,10 @@ use std::sync::Arc; -use promptforge_tools::{Tool, ToolCatalog}; use tokio::sync::mpsc; use workshop_gateway::GatewayBinding; -use crate::input::UserInputTool; - use super::{AgentSession, AgentSessions, SessionHost}; mod catalog; mod effects; @@ -30,24 +27,11 @@ pub(super) fn spawn( cancellations: mpsc::Receiver, ) { tokio::spawn(async move { - let tool: Arc = Arc::new(UserInputTool::new( - Arc::clone(&session.waits), - session.input_frames.clone(), - )); - let tools = match ToolCatalog::new(&[tool]) { - Ok(tools) => tools, - Err(error) => { - tracing::error!(%error, session = %session.id, "agent tool catalog refused"); - registry.forget(&session.id); - return; - } - }; let (mut collector, initial_catalog, initial_gateway) = EventCollector::new(lifecycle, cancellations, host.catalog().clone(), gateway); let mut executor = EffectExecutor::new( Arc::clone(&session), host, - tools, initial_catalog.snapshot, Arc::clone(&initial_gateway), ); diff --git a/crates/workshop-sessions/src/agents/supervisor/effects.rs b/crates/workshop-sessions/src/agents/supervisor/effects.rs index 04736b2a5..98f7e6cf1 100644 --- a/crates/workshop-sessions/src/agents/supervisor/effects.rs +++ b/crates/workshop-sessions/src/agents/supervisor/effects.rs @@ -2,23 +2,20 @@ use std::sync::Arc; -use promptforge_agent::{AgentConfig, AgentError, AgentLimits, run_agent_with_client}; -use promptforge_core::execute::RunErrorKind; -use promptforge_core::{Prompt, ResolutionContext, RunConfig}; -use promptforge_core_support::observe::Observer; -use promptforge_model_client::client::{GatewayClient as ModelClient, StreamDelta}; -use promptforge_model_client::model::ModelCatalog; -use promptforge_tool_picker::{Config, ToolPicker}; -use promptforge_tools::ToolCatalog; -use shared_vfs::VfsRef; +use promptforge_api::client::GatewayClient as ModelClient; +use promptforge_api::execute::RunErrorKind; +use promptforge_api::{Prompt, ResolutionContext, RunConfig}; +use shared_promptforge_api::models::ModelCatalog; +use shared_promptforge_api::observe::Observer; +use shared_promptforge_api::tools::ToolCatalog; +use shared_promptforge_api::wire::StreamDelta; use workshop_gateway::GatewaySnapshot; use workshop_menu::ChatCatalog; use workshop_protocol::Activity; use crate::agents::{ - AgentSession, AgentSource, SessionHost, SessionObserver, build_model_catalog, delta_stamp, - ui_provider, + AgentSession, AgentSource, SessionHost, SessionObserver, agent_client, delta_stamp, ui_provider, }; use crate::input::SessionInputBroker; @@ -28,6 +25,26 @@ use super::transition::{ RunId, SupervisorEffect, SupervisorEvent, }; +/// One agent run's terminal outcome, session-local: cancellation maps to +/// the interrupted stop reason and every other failure to an +/// operator-facing message. Replaces the retired agent runtime's +/// `AgentError`, which named Lua-specific failure shapes no session run +/// can produce. +#[derive(Debug, thiserror::Error)] +pub(super) enum AgentRunError { + /// The run was cancelled: a stop reason, never a failure. + #[error("the agent run was interrupted")] + Interrupted, + /// The run failed; the message is operator-facing. + #[error("{message}")] + Failed { + /// What failed, operator-facing. + message: String, + /// The underlying error, when one exists. + source: Option>, + }, +} + /// The result of executing one reducer-selected effect. pub(super) enum EffectOutcome { Continue, @@ -38,20 +55,14 @@ pub(super) enum EffectOutcome { /// Immutable resources reused by each reducer-selected relaunch. struct RunFactory { session: Arc, - tools: ToolCatalog, - vfs: VfsRef, observer: Arc, on_delta: Arc, ui: Arc serde_json::Value + Send + Sync>, - /// The tool picker the unified runtime's resolution context borrows; - /// a cheap empty picker that never loads the embedding model, only - /// for Markdown agents, which never bind tools through it today. - picker: Option>, } impl RunFactory { /// Builds reusable run resources for one session. - fn new(session: Arc, tools: ToolCatalog, host: &SessionHost) -> Self { + fn new(session: Arc, host: &SessionHost) -> Self { let observer: Arc = Arc::new(SessionObserver { log: Arc::clone(&session.log), rounds: Arc::clone(&session.rounds), @@ -60,55 +71,18 @@ impl RunFactory { errors: session.errors.clone(), lifecycle: Arc::clone(&session.lifecycle), }); - let picker = match &session.source { - AgentSource::Markdown(_) => Some(Arc::new(ToolPicker::empty(Config::default()))), - AgentSource::Lua(_) => None, - }; Self { on_delta: delta_stamp(&session, &host.push()), ui: ui_provider(host.menu(), host.registry()), session, - tools, - vfs: promptforge_vfs::empty(), observer, - picker, } } /// Builds one run over retained history and frozen bindings. - fn launch(&self, run: RunId, models: Vec, client: ModelClient) -> RunFuture { - match self.session.source.clone() { - AgentSource::Lua(source) => self.launch_lua(run, source, models, client), - AgentSource::Markdown(source) => self.launch_markdown(run, source, client), - } - } - - /// Builds one agent-runtime run of a standalone Lua program. - fn launch_lua( - &self, - run: RunId, - source: String, - models: Vec, - client: ModelClient, - ) -> RunFuture { - let tools = self.tools.clone(); - let models = build_model_catalog(Some(models)); - let vfs = self.vfs.clone(); - let config = AgentConfig { - name: self.session.agent.clone(), - execution: self.session.id.clone(), - observer: Arc::clone(&self.observer), - cancel: self.session.arm_cancel(run), - event_log: Some(Arc::clone(&self.session.log) as _), - on_delta: Some(Arc::clone(&self.on_delta)), - ui: Some(Arc::clone(&self.ui)), - limits: AgentLimits::default(), - }; - Box::pin(async move { - let result = - run_agent_with_client(&source, &tools, &models, &vfs, config, Some(client)).await; - (run, result) - }) + fn launch(&self, run: RunId, client: ModelClient) -> RunFuture { + let AgentSource::Markdown(source) = self.session.source.clone(); + self.launch_markdown(run, source, client) } /// Builds one unified-runtime run of a Markdown prompt document. @@ -118,12 +92,6 @@ impl RunFactory { observer: Arc::clone(&self.observer), ui: Arc::clone(&self.ui), on_delta: Arc::clone(&self.on_delta), - vfs: self.vfs.clone(), - picker: Arc::clone( - self.picker - .as_ref() - .unwrap_or_else(|| unreachable!("a Markdown agent session built its picker")), - ), }; Box::pin(async move { let result = run_markdown_agent(&source, parts, run, client).await; @@ -139,31 +107,28 @@ struct MarkdownRunParts { observer: Arc, ui: Arc serde_json::Value + Send + Sync>, on_delta: Arc, - vfs: VfsRef, - picker: Arc, } /// Runs one Markdown agent prompt on the unified runtime: the session's /// wait registry behind the generic input broker, the menu selection /// behind `ui().selected_model`, deltas forwarded to the session's /// channel. The prompt declares no capabilities, so the resolution -/// context carries an empty catalog pair and the session's picker. +/// context carries no picker and the run config keeps its stock store +/// handle. async fn run_markdown_agent( source: &str, parts: MarkdownRunParts, run: RunId, client: ModelClient, -) -> Result<(), AgentError> { +) -> Result<(), AgentRunError> { let MarkdownRunParts { session, observer, ui, on_delta, - vfs, - picker, } = parts; let prompt = Prompt::parse(source, &session.id, observer.as_ref()).map_err(|error| { - AgentError::Program { + AgentRunError::Failed { message: format!("the embedded Markdown agent failed to parse: {error}"), source: Some(Box::new(error)), } @@ -173,8 +138,7 @@ async fn run_markdown_agent( session.input_frames.clone(), )); let models = ModelCatalog::empty(); - let tools = ToolCatalog::new(&[]) - .map_err(|_error| AgentError::Internal("an empty tool catalog is always valid"))?; + let tools = ToolCatalog::default(); let config = RunConfig::new(session.id.clone()) .observer(observer) .client(client) @@ -182,18 +146,17 @@ async fn run_markdown_agent( .input_broker(broker) .ui(ui) .on_delta(on_delta); - promptforge_core::run( + promptforge_api::run( &prompt, "", - ResolutionContext::new(picker.as_ref(), &models, &tools), - &vfs, + ResolutionContext::new(None, &models, &tools), config, ) .await .map(|_output| ()) .map_err(|error| match error.kind() { - RunErrorKind::Cancelled => AgentError::Interrupted, - _ => AgentError::Program { + RunErrorKind::Cancelled => AgentRunError::Interrupted, + _ => AgentRunError::Failed { message: error.to_string(), source: Some(Box::new(error)), }, @@ -217,12 +180,11 @@ impl EffectExecutor { pub(super) fn new( session: Arc, host: SessionHost, - tools: ToolCatalog, initial_catalog: Option, initial_gateway: Arc, ) -> Self { Self { - factory: RunFactory::new(Arc::clone(&session), tools, &host), + factory: RunFactory::new(Arc::clone(&session), &host), session, host, latest_catalog: initial_catalog, @@ -309,7 +271,7 @@ impl EffectExecutor { ); return failed_relaunch(relaunch.run); }; - let Some(client) = gateway.model_client() else { + let Some(client) = agent_client(gateway.base_url(), gateway.api_key()) else { report_failure( &self.session, &self.host, @@ -320,9 +282,9 @@ impl EffectExecutor { match relaunch.history { HistoryEffect::Preserve => {} } - self.active_catalog = Some(catalog.clone()); + self.active_catalog = Some(catalog); self.active_gateway = Some(gateway); - self.active_run = Some(self.factory.launch(relaunch.run, catalog.models, client)); + self.active_run = Some(self.factory.launch(relaunch.run, client)); EffectOutcome::Continue } } @@ -330,12 +292,12 @@ impl EffectExecutor { /// Converts one run result into its typed reducer event. fn run_completion_event( run: RunId, - result: Result<(), AgentError>, + result: Result<(), AgentRunError>, session: &AgentSession, host: &SessionHost, ) -> SupervisorEvent { let result = match result { - Err(AgentError::Interrupted) => RunCompletion::Interrupted, + Err(AgentRunError::Interrupted) => RunCompletion::Interrupted, Ok(()) => RunCompletion::Completed, Err(error) => { tracing::warn!( diff --git a/crates/workshop-sessions/src/agents/supervisor/events.rs b/crates/workshop-sessions/src/agents/supervisor/events.rs index 105656239..2263e8f68 100644 --- a/crates/workshop-sessions/src/agents/supervisor/events.rs +++ b/crates/workshop-sessions/src/agents/supervisor/events.rs @@ -4,17 +4,18 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use promptforge_agent::AgentError; use tokio::sync::{mpsc, watch}; use workshop_gateway::{GatewayBinding, GatewaySnapshot}; use workshop_menu::CatalogBus; use super::catalog::{CatalogEvent, current_catalog_event, next_catalog_event}; +use super::effects::AgentRunError; use super::transition::{RunId, SupervisorEvent}; /// One owned run future paired with its reducer identity. -pub(super) type RunFuture = Pin)> + Send>>; +pub(super) type RunFuture = + Pin)> + Send>>; /// Runtime data collected alongside one pure supervisor event. pub(super) enum CollectedEvent { @@ -26,7 +27,7 @@ pub(super) enum CollectedEvent { }, Run { run: RunId, - result: Result<(), AgentError>, + result: Result<(), AgentRunError>, }, } diff --git a/crates/workshop-sessions/src/agents/tests.rs b/crates/workshop-sessions/src/agents/tests.rs index 448cad2a5..9193a5fcc 100644 --- a/crates/workshop-sessions/src/agents/tests.rs +++ b/crates/workshop-sessions/src/agents/tests.rs @@ -1,8 +1,8 @@ use std::sync::atomic::AtomicU64; -use promptforge_core_support::events::RuntimeEventKind; -use promptforge_core_support::observe::{Observation, Observer}; -use promptforge_model_client::model::{ModelCatalog, ThinkingMode}; +use shared_promptforge_api::events::RuntimeEventKind; +use shared_promptforge_api::models::ModelCatalog; +use shared_promptforge_api::observe::{Observation, Observer}; use workshop_protocol::Activity; use super::*; @@ -21,17 +21,18 @@ fn wired_push( } #[test] -fn discovery_lists_sorted_lua_stems_and_tolerates_a_missing_dir() { +fn discovery_lists_sorted_markdown_stems_and_tolerates_a_missing_dir() { let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write(dir.path().join("zeta.lua"), "return 1").expect("seed zeta"); - std::fs::write(dir.path().join("alpha.lua"), "return 1").expect("seed alpha"); + std::fs::write(dir.path().join("zeta.md"), "# zeta").expect("seed zeta"); + std::fs::write(dir.path().join("alpha.md"), "# alpha").expect("seed alpha"); std::fs::write(dir.path().join("notes.txt"), "not an agent").expect("seed noise"); - std::fs::create_dir(dir.path().join("nested.lua")).expect("seed a decoy directory"); + std::fs::write(dir.path().join("legacy.lua"), "return 1").expect("seed a retired Lua program"); + std::fs::create_dir(dir.path().join("nested.md")).expect("seed a decoy directory"); assert_eq!( discover_agents(dir.path()), vec!["alpha".to_owned(), "chat".to_owned(), "zeta".to_owned()], - "discovery lists .lua file stems plus the built-in chat, sorted, \ - and skips everything else" + "discovery lists .md file stems plus the built-in chat, sorted, \ + and skips everything else - a .lua file is never an agent" ); assert_eq!( discover_agents(&dir.path().join("missing")), @@ -54,16 +55,24 @@ fn the_built_in_chat_is_always_offered_and_a_dir_file_shadows_its_source() { "with no directory file, the embedded source is what launches" ); - std::fs::write(dir.path().join("chat.lua"), "-- shadowed").expect("seed the shadow"); + std::fs::write(dir.path().join("chat.md"), "# shadowed").expect("seed the shadow"); assert_eq!( discover_agents(dir.path()), vec!["chat".to_owned()], - "a directory chat.lua lists once, never beside the built-in" + "a directory chat.md lists once, never beside the built-in" ); assert_eq!( agent_source(dir.path(), "chat").expect("the shadow reads"), - AgentSource::Lua("-- shadowed".to_owned()), - "a directory chat.lua shadows the embedded source" + AgentSource::Markdown("# shadowed".to_owned()), + "a directory chat.md shadows the embedded source" + ); + + std::fs::remove_file(dir.path().join("chat.md")).expect("clear the shadow"); + std::fs::write(dir.path().join("chat.lua"), "-- retired").expect("seed a retired shadow"); + assert_eq!( + agent_source(dir.path(), "chat").expect("the built-in still serves"), + AgentSource::Markdown(BUILTIN_CHAT_SOURCE.to_owned()), + "a directory chat.lua shadows nothing: the Lua path is retired" ); assert_eq!( @@ -76,55 +85,18 @@ fn the_built_in_chat_is_always_offered_and_a_dir_file_shadows_its_source() { } #[test] -fn an_unreadable_chat_lua_surfaces_its_error_rather_than_the_built_in() { +fn an_unreadable_chat_md_surfaces_its_error_rather_than_the_built_in() { let dir = tempfile::TempDir::new().expect("tempdir"); - // A directory named chat.lua cannot be read as a file on any + // A directory named chat.md cannot be read as a file on any // platform, and its failure is never NotFound - the one kind // that falls back to the embedded source. - std::fs::create_dir(dir.path().join("chat.lua")).expect("seed the unreadable shadow"); + std::fs::create_dir(dir.path().join("chat.md")).expect("seed the unreadable shadow"); agent_source(dir.path(), "chat").expect_err( - "an existing chat.lua that cannot be read surfaces its error; \ + "an existing chat.md that cannot be read surfaces its error; \ silently serving the built-in would mask the operator's own file", ); } -#[test] -fn the_model_catalog_keeps_chat_entries_and_skips_the_rest() { - let catalog = build_model_catalog(Some(vec![ - serde_json::json!({ - "id": "chat-model", "kind": "chat", "description": "a chat model", - "context": 4096, "thinking": "switchable", - }), - serde_json::json!({ "id": "plain-openai-model" }), - serde_json::json!({ "id": "embed-model", "kind": "embedding" }), - serde_json::json!({ "object": "model" }), - serde_json::json!({ "id": "chat-model" }), - ])); - let names: Vec<&str> = catalog - .models() - .iter() - .map(|descriptor| descriptor.id().name()) - .collect(); - assert_eq!( - names, - vec!["chat-model", "plain-openai-model"], - "chat and kind-less entries stay; embeddings, id-less rows, and duplicates drop" - ); - let chat = &catalog.models()[0]; - assert_eq!(chat.context().get(), 4096); - assert_eq!(chat.thinking(), ThinkingMode::Switchable); - let bare = &catalog.models()[1]; - assert_eq!( - bare.context().get(), - super::session::FALLBACK_CONTEXT, - "an entry without a context window records the fallback" - ); - assert!( - build_model_catalog(None).is_empty(), - "no retained catalog means an empty agent catalog" - ); -} - #[test] fn reply_stamps_follow_the_settle_rule() { let mut rounds = 0; @@ -190,7 +162,7 @@ fn the_ui_snapshot_serves_the_selection_and_first_granted_root() { #[test] fn a_launch_without_a_usable_client_is_refused() { let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write(dir.path().join("echo.lua"), "return 1").expect("seed echo"); + std::fs::write(dir.path().join("echo.md"), "# echo").expect("seed echo"); let catalog = CatalogBus::default(); let menu = MenuBus::new(catalog.clone(), None); let registry = Registry::new(); @@ -256,42 +228,35 @@ async fn a_failed_model_turn_pushes_a_terminal_failure_status() { #[test] fn the_model_client_requires_a_usable_key_and_url() { assert!( - workshop_gateway::gateway_binding::model_client("http://127.0.0.1:8081", "k").is_some(), + agent_client("http://127.0.0.1:8081", "k").is_some(), "a keyed gateway builds the agent model client" ); assert!( - workshop_gateway::gateway_binding::model_client("http://127.0.0.1:8081", "").is_none(), + agent_client("http://127.0.0.1:8081", "").is_none(), "an empty key cannot authenticate: agents report it at launch" ); - assert!(workshop_gateway::gateway_binding::model_client("not a url", "k").is_none()); + assert!(agent_client("not a url", "k").is_none()); } /// Runs the embedded chat prompt on the unified runtime with the given /// broker configuration, against a client no model call can survive. async fn run_builtin_chat( - broker: Option>, -) -> Result { - use promptforge_core::{Prompt, ResolutionContext, RunConfig}; + broker: Option>, +) -> Result { + use promptforge_api::{Prompt, ResolutionContext, RunConfig}; let observer: Arc = Arc::new(WorkshopObserver::new(None).expect("memory log")); let prompt = Prompt::parse(BUILTIN_CHAT_SOURCE, "chat-unit", observer.as_ref()) .expect("the embedded chat prompt parses"); - let picker = promptforge_tool_picker::ToolPicker::build( - promptforge_tool_picker::Catalog::new(Vec::new()), - promptforge_tool_picker::Config::default(), - ) - .expect("the empty picker builds"); let models = ModelCatalog::empty(); - let tools = promptforge_tools::ToolCatalog::new(&[]).expect("an empty catalog is valid"); - let store = promptforge_vfs::empty(); + let tools = shared_promptforge_api::tools::ToolCatalog::default(); let mut config = RunConfig::new("chat-unit").observer(observer); if let Some(broker) = broker { config = config.input_broker(broker); } - promptforge_core::run( + promptforge_api::run( &prompt, "", - ResolutionContext::new(&picker, &models, &tools), - &store, + ResolutionContext::new(None, &models, &tools), config, ) .await @@ -314,14 +279,14 @@ async fn a_failing_broker_fails_the_builtin_chat_as_typed_input() { struct FailingBroker; #[async_trait::async_trait] - impl promptforge_core::input::InputBroker for FailingBroker { + impl promptforge_api::input::InputBroker for FailingBroker { async fn user_input( &self, _execution: &str, _section: &str, - ) -> Result + ) -> Result { - Err(promptforge_core::input::InputError::message( + Err(promptforge_api::input::InputError::message( "the input device is gone", )) } @@ -331,7 +296,7 @@ async fn a_failing_broker_fails_the_builtin_chat_as_typed_input() { .await .expect_err("the broker failure fails the run"); assert!( - matches!(error.kind(), promptforge_core::execute::RunErrorKind::Input), + matches!(error.kind(), promptforge_api::execute::RunErrorKind::Input), "a broker failure is the typed input failure: {error}" ); } diff --git a/crates/workshop-sessions/src/input.rs b/crates/workshop-sessions/src/input.rs index ab38c1e31..e64e24dd6 100644 --- a/crates/workshop-sessions/src/input.rs +++ b/crates/workshop-sessions/src/input.rs @@ -1,10 +1,10 @@ //! The user-input wait: the [`WaitRegistry`] of single-use wait tokens, -//! the Workshop's `user_input` tool, and the `input_response` producer -//! that completes a wait. +//! the session's input broker behind the script-side `user_input()`, and +//! the `input_response` producer that completes a wait. //! -//! An agent program asks its operator for input by calling the -//! `user_input` tool - session-supplied code, never advertised to a -//! model. Its `call()` registers a wait, announces it with a durable +//! An agent program asks its operator for input through the session's +//! input broker - session-supplied code, never advertised to a model. The +//! broker registers a wait, announces it with a durable //! `input_required` frame, and suspends on the wait's receiver until the //! session delivers the operator's answer ([`deliver_input_response`]) or //! the wait dies. A dying wait is an outcome, never silence: every path @@ -19,12 +19,12 @@ mod tool; use std::fmt; use std::sync::{Mutex, MutexGuard, PoisonError}; -use promptforge_core_support::observe::Observer; +use shared_promptforge_api::observe::Observer; use tokio::sync::{broadcast, oneshot}; use workshop_protocol::{InputFrame, InputResponse}; -pub use tool::{SessionInputBroker, UserInputTool}; +pub use tool::SessionInputBroker; /// One unresolved wait: its single-use token, and the sender that resumes /// the suspended `user_input` call with the operator's text. @@ -246,7 +246,7 @@ pub enum WaitError { /// /// # Examples /// ``` -/// use promptforge_core_support::observe::NullObserver; +/// use shared_promptforge_api::observe::NullObserver; /// use workshop_protocol::InputResponse; /// use workshop_sessions::{WaitRegistry, deliver_input_response}; /// diff --git a/crates/workshop-sessions/src/input/tests.rs b/crates/workshop-sessions/src/input/tests.rs index dc6141b83..7c9d5e5df 100644 --- a/crates/workshop-sessions/src/input/tests.rs +++ b/crates/workshop-sessions/src/input/tests.rs @@ -2,36 +2,13 @@ use super::*; use std::sync::Arc; -use promptforge_core::input::{InputBroker, InputOutcome}; -use promptforge_core_support::observe::Observation; -use promptforge_tools::{OutputTrust, Tool, ToolErrorKind}; +use promptforge_api::input::{InputBroker, InputOutcome}; +use shared_promptforge_api::observe::Observation; /// Hostile operator text covering the bytes most likely to be mangled /// by an envelope or codec. const GNARLY: &str = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash \u{1F980}"; -/// A fresh tool, registry, and channel with no subscribers. -fn tool_fixture() -> ( - UserInputTool, - Arc, - broadcast::Sender, -) { - let registry = Arc::new(WaitRegistry::new()); - let (frames, _) = broadcast::channel(8); - let tool = UserInputTool::new(Arc::clone(®istry), frames.clone()); - (tool, registry, frames) -} - -async fn registered_token(registry: &WaitRegistry) -> String { - for _ in 0..1024 { - if let Some(token) = registry.unresolved().first().cloned() { - return token; - } - tokio::task::yield_now().await; - } - panic!("the tool call never registered its wait"); -} - async fn required_token(socket: &mut broadcast::Receiver) -> String { let frame = socket.recv().await.expect("a frame arrives"); let InputFrame::Required { token } = frame else { @@ -134,143 +111,6 @@ fn the_registry_debug_shows_the_count_and_never_a_token() { ); } -#[test] -fn the_tool_declares_structured_output() { - let (tool, _registry, _frames) = tool_fixture(); - assert!( - tool.structured_output(), - "user_input must bind structured so its JSON resumes as a Lua table" - ); -} - -#[tokio::test] -async fn the_tool_emits_input_required_carrying_its_wait_token() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - assert_eq!( - registry.unresolved(), - vec![token.clone()], - "the announced token names the retained wait" - ); - registry - .complete(&token, "done".to_owned()) - .expect("the wait completes"); - let output = call - .await - .expect("the task joins") - .expect("the call succeeds"); - assert_eq!(output.trust(), OutputTrust::Trusted); -} - -#[tokio::test] -async fn the_resumed_output_is_a_trusted_table_with_byte_exact_text_and_empty_images() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - registry - .complete(&token, GNARLY.to_owned()) - .expect("the wait completes"); - let output = call - .await - .expect("the task joins") - .expect("the call succeeds"); - assert_eq!( - output.trust(), - OutputTrust::Trusted, - "operator input is first-party: no nonce envelope may wrap it" - ); - let table: serde_json::Value = - serde_json::from_str(output.text()).expect("a structured tool returns JSON"); - assert_eq!( - table["text"].as_str().expect("text is a string"), - GNARLY, - "result.text is the SPA text byte-exact and envelope-free" - ); - assert_eq!( - table["images"], - serde_json::json!([]), - "result.images is present and empty in the gate" - ); - assert!( - matches!( - socket.try_recv(), - Err(broadcast::error::TryRecvError::Empty) - ), - "a completed wait dies silently: no input_cancelled follows" - ); -} - -#[tokio::test] -async fn dropping_the_tool_future_removes_the_wait_and_emits_input_cancelled() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - call.abort(); - let joined = call.await; - assert!( - joined.is_err_and(|error| error.is_cancelled()), - "abort drops the suspended call" - ); - assert!( - registry.unresolved().is_empty(), - "a dropped future may not leak its wait" - ); - let frame = socket.recv().await.expect("the cancellation frame arrives"); - assert_eq!( - frame, - InputFrame::Cancelled { token }, - "the SPA is told exactly which prompt died" - ); -} - -#[tokio::test] -async fn a_registry_cancel_fails_the_call_as_cancelled_and_emits_input_cancelled() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - registry.cancel(&token); - let error = call - .await - .expect("the task joins") - .expect_err("a cancelled wait fails the call"); - assert_eq!(error.kind(), ToolErrorKind::Cancelled); - let frame = socket.recv().await.expect("the cancellation frame arrives"); - assert_eq!( - frame, - InputFrame::Cancelled { token }, - "cancellation is an outcome on the wire, not silence" - ); -} - -#[tokio::test] -async fn a_disconnected_socket_does_not_cancel_the_wait() { - let (tool, registry, frames) = tool_fixture(); - // No subscriber exists at all: the session's socket is gone. - drop(frames); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = registered_token(®istry).await; - assert_eq!( - registry.unresolved(), - vec![token.clone()], - "the wait outlives the absent socket" - ); - registry - .complete(&token, "typed after reconnect".to_owned()) - .expect("the retained wait still completes"); - let output = call - .await - .expect("the task joins") - .expect("the call succeeds"); - let table: serde_json::Value = - serde_json::from_str(output.text()).expect("a structured tool returns JSON"); - assert_eq!(table["text"], "typed after reconnect"); -} - #[tokio::test] async fn reconnect_resends_unresolved_waits_in_creation_order() { let registry = WaitRegistry::new(); diff --git a/crates/workshop-sessions/src/input/tool.rs b/crates/workshop-sessions/src/input/tool.rs index 4f8e70771..5b74e742b 100644 --- a/crates/workshop-sessions/src/input/tool.rs +++ b/crates/workshop-sessions/src/input/tool.rs @@ -1,81 +1,19 @@ -//! The session's input tools: the Workshop's `user_input` tool and the -//! generic input broker, each suspending an agent program until its -//! operator answers, each guarded so a dying wait is an outcome, never +//! The session's input broker: suspends an agent program until its +//! operator answers, guarded so a dying wait is an outcome, never //! silence. use std::sync::Arc; -use promptforge_core::input::{InputBroker, InputError, InputOutcome}; -use promptforge_tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; +use promptforge_api::input::{InputBroker, InputError, InputOutcome}; use tokio::sync::broadcast; use workshop_protocol::InputFrame; use super::WaitRegistry; -/// The Workshop's `user_input` tool: suspends an agent program until its -/// operator types into the session's input box. -/// -/// A host-primitive [`Tool`] the session constructs per agent session - -/// it is never advertised to a model (the agent driver advertises only -/// the aliases a `models.chat` call names, and host primitives are -/// excluded from that set), and only the agent program itself calls it. -/// `call()` opens a wait in the session's [`WaitRegistry`], pushes the -/// `input_required` frame itself, and suspends until the wait resolves; -/// `run_agent` has no user-input awareness because this tool is the -/// caller's own code. -/// -/// The output is **trusted and structured**: a JSON object with `text` -/// (the operator's input, byte-exact - the operator is not an attacker of -/// their own session, so no nonce envelope ever wraps it) and `images` -/// (present and always empty until SPA attachments land). The session -/// binds this tool with the structured output kind, so the object resumes -/// into Lua as a table - `result.text`, `result.images` - through the -/// serde boundary; structured output stays restricted to trusted tools. -/// -/// # Examples -/// ``` -/// use std::sync::Arc; -/// -/// use promptforge_tools::Tool; -/// use workshop_sessions::{UserInputTool, WaitRegistry}; -/// -/// let (frames, _receiver) = tokio::sync::broadcast::channel(8); -/// let tool = UserInputTool::new(Arc::new(WaitRegistry::new()), frames); -/// assert_eq!(tool.wire_name(), "user_input"); -/// ``` -#[derive(Debug)] -pub struct UserInputTool { - /// The session's wait registry, shared with the session loop that - /// completes and cancels waits. - registry: Arc, - /// Where `input_required` and `input_cancelled` frames are pushed; - /// the session's socket loop forwards them to the SPA. - frames: broadcast::Sender, -} - -impl UserInputTool { - /// Builds the tool over the session's wait registry and frame sender. - /// - /// # Examples - /// ``` - /// use std::sync::Arc; - /// - /// use workshop_sessions::{UserInputTool, WaitRegistry}; - /// - /// let registry = Arc::new(WaitRegistry::new()); - /// let (frames, _receiver) = tokio::sync::broadcast::channel(8); - /// let _tool = UserInputTool::new(registry, frames); - /// ``` - #[must_use] - pub fn new(registry: Arc, frames: broadcast::Sender) -> Self { - Self { registry, frames } - } -} - /// Guarantees a dying wait is an outcome, not silence: unless disarmed by /// a delivered value, dropping the guard removes the wait from the -/// registry and pushes `input_cancelled` for its token. The tool future +/// registry and pushes `input_cancelled` for its token. The broker future /// is dropped by the shared dispatch's cancel race on turn-cancel, so /// this guard is what keeps a cancelled turn from leaking its wait or /// leaving the SPA prompting against a dead token. @@ -115,8 +53,8 @@ impl Drop for WaitGuard { /// One broker per run: `user_input` opens a wait in the session's /// [`WaitRegistry`], announces it with the durable `input_required` frame, /// and suspends on the receiver until the session delivers the operator's -/// answer or the wait dies. A dying wait is an outcome, never silence - -/// the same drop-guard rule as the legacy tool: a future dropped by a +/// answer or the wait dies. A dying wait is an outcome, never silence: +/// a future dropped by a /// turn-cancel removes the entry and pushes `input_cancelled`, so the SPA /// never pins its input box to a dead token. /// @@ -203,77 +141,3 @@ impl InputBroker for SessionInputBroker { } } } - -#[async_trait::async_trait] -impl Tool for UserInputTool { - fn id(&self) -> ToolId { - ToolId::from_validated("workshop", "user_input") - } - - #[expect( - clippy::unnecessary_literal_bound, - reason = "the Tool trait fixes this return type to &str, so the &'static str suggestion cannot be applied" - )] - fn wire_name(&self) -> &str { - "user_input" - } - - #[expect( - clippy::unnecessary_literal_bound, - reason = "the Tool trait fixes this return type to &str, so the &'static str suggestion cannot be applied" - )] - fn description(&self) -> &str { - "Waits for the workshop operator to type into the session's input box." - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ "type": "object", "properties": {} }) - } - - /// Structured: the JSON object resumes into Lua as a table - /// (`result.text`, `result.images`), which is safe here because the - /// output is trusted - the untrusted wrap that would break a JSON - /// parse never applies. - fn structured_output(&self) -> bool { - true - } - - /// Opens a wait, announces it, and suspends until it resolves. - /// - /// Arguments are ignored: the tool takes none. On cancellation - the - /// future dropped mid-await, or the wait cancelled out of the - /// registry - the drop guard removes the wait and pushes - /// `input_cancelled`, so no path leaks a wait or a stale prompt. - /// - /// # Errors - /// Returns a [`ToolErrorKind::Cancelled`] error when the wait dies - /// before the operator answers. - async fn call(&self, _args: serde_json::Value) -> Result { - let (token, receiver) = self.registry.create(); - let mut guard = WaitGuard { - registry: Arc::clone(&self.registry), - frames: self.frames.clone(), - token, - armed: true, - }; - // No receiver means no socket is attached right now. Not a - // failure: the registry retains the wait and the session resends - // it on reconnect, so the lost push is repaired. - let _ = self.frames.send(InputFrame::Required { - token: guard.token.clone(), - }); - match receiver.await { - Ok(text) => { - guard.armed = false; - let table = serde_json::json!({ "text": text, "images": [] }); - Ok(ToolOutput::trusted(table.to_string())) - } - // The sender died without a value: the wait was cancelled out - // of the registry. The still-armed guard pushes - // `input_cancelled` on scope exit, so this path clears the - // SPA prompt too. - Err(_) => Err(ToolError::message("the user-input wait was cancelled") - .with_kind(ToolErrorKind::Cancelled)), - } - } -} diff --git a/crates/workshop-sessions/src/lib.rs b/crates/workshop-sessions/src/lib.rs index b1379ef96..26fedfe3f 100644 --- a/crates/workshop-sessions/src/lib.rs +++ b/crates/workshop-sessions/src/lib.rs @@ -33,7 +33,5 @@ mod session; pub mod state; pub use agents::{AgentSessions, SessionHost}; -pub use input::{ - SessionInputBroker, UserInputTool, WaitError, WaitRegistry, deliver_input_response, -}; +pub use input::{SessionInputBroker, WaitError, WaitRegistry, deliver_input_response}; pub use state::{SessionsState, register, routes}; diff --git a/crates/workshop-support/src/config.rs b/crates/workshop-support/src/config.rs index a647f5e8c..5df409a21 100644 --- a/crates/workshop-support/src/config.rs +++ b/crates/workshop-support/src/config.rs @@ -163,10 +163,14 @@ impl Default for ServerConfig { #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(default)] pub struct AgentsConfig { - /// Directory whose `.lua` files are the launchable agent programs. - /// Defaults to `agents/` beside the config file (`Config::parse` - /// anchors the empty default there). A missing directory means no - /// agents are offered - a state, not an error. + /// Directory whose `.md` files are the launchable agent prompts, + /// discovered by file stem. A `chat.md` in the directory shadows the + /// embedded built-in chat agent; an existing `chat.md` that cannot + /// be read surfaces an error rather than silently serving the + /// built-in. Defaults to `agents/` beside the config file + /// (`Config::parse` anchors the empty default there). A missing + /// directory still offers the embedded `chat` built-in - a state, + /// not an error. pub path: PathBuf, } diff --git a/guide/promptforge-agent-guide.md b/guide/promptforge-agent-guide.md index 74ca9fc22..9bb2d60bd 100644 --- a/guide/promptforge-agent-guide.md +++ b/guide/promptforge-agent-guide.md @@ -4,39 +4,57 @@ # Agent programs -This chapter teaches you what an agent program is, the file you write, and how the host runs it. Learn it first, because everything an agent does - talking to a model, calling a tool, reading what happened - is a call made from this one file. +This chapter teaches you what an agent is, the file you write, and how the Workshop runs it. Learn it first, because an agent is an ordinary PromptForge prompt document: everything the prompt language gives a prompt - sections, Lua blocks, model rounds, tools, the store - an agent has too. What makes it an agent is only where the file lives and who is listening. ## Write the smallest working agent -````lua +````markdown +--- +name: hello +description: The smallest working agent. +promptforge: 0 +--- + +# Hello + +## Speak + +```lua log('hello from my agent') +``` ```` -Save that one line in a file named `hello.lua`. The file is the whole agent. When the host runs it, the `log` call records the message `hello from my agent` in the run's event stream, and the program runs to its end. +Save that file as `hello.md` in the agents directory. The file is the whole agent: frontmatter that makes it a prompt, one title, one section, one Lua block. There is no manifest, no registration step, and no second file. When the host runs it, the `log` call records the message `hello from my agent` in the run's event stream, and the prompt runs to its end. -An agent is one `.lua` program. There is no manifest, no registration step, and no second file. The program you save is the program the host runs. +## How the Workshop runs an agent -## How the host runs the program +The Workshop discovers agents by reading the agents directory: every `.md` file there is a launchable agent, listed under its file-stem name in a sorted list. Discovery reads the directory per request, so a file you add shows up in the agent list on the next connect, with no restart. A missing or unreadable directory is a state, not an error: the list simply offers the built-in chat alone. -The host compiles your file as Lua 5.5 and runs it as a single long-running Lua coroutine. One run is one coroutine, driven from the first line to the end of the program. +Launching an agent parses the file as a PromptForge prompt and runs it on the unified document runtime, the same runtime that runs every other prompt. One launch is one prompt run: sections walk in order, Lua blocks suspend on host calls and resume with their answers, and the run ends when the document ends - or when the operator cancels it. -Your program keeps its own local state across the whole session. A local variable you set early is still there at the end, because the same coroutine runs every line. +The directory itself is a configuration value: `agents.path` in `workshop.toml`. The default is `agents/` beside the config file. ## The agent's name -The agent's name is the `.lua` file stem. Save the program as `hello.lua` and the agent's name is `hello`. Agents have no sections, so that name is the whole identity. +The agent's name is the `.md` file stem. Save the prompt as `hello.md` and the agent's name is `hello`. Discovery yields bare stems only, so a launch request can never be coaxed into naming a path. + +The name follows the run everywhere it leaves a trace: the agent list, the session panel, and the persisted event log all key on it. + +## The built-in chat and the shadow + +A fresh install always offers a working chat agent, even when there is no agents directory at all. The built-in `chat` is a Markdown prompt embedded in the Workshop at compile time, and discovery always lists it. -Every event your agent emits carries the agent's name as its section label. The workshop UI and the event log both key on that name, so the name in the file stem is the name you see everywhere the run leaves a trace. +Save your own prompt as `chat.md` in the agents directory and it shadows the embedded source: the list still shows one `chat`, but launching it runs your file. That is how your own agent takes over the chat role. An existing `chat.md` that cannot be read surfaces its error instead of silently serving the embedded source. -## The host surface +## The session surface -Your program reaches the host through a shared set of calls. `models.infer` runs one model completion. `tools.call` dispatches a tool. `store` reads and writes files. `var` holds per-run state. `log` records a message in the event stream. Cooperative cancellation lets the host stop the run. +An agent prompt runs with two extras an unattached prompt does not have, both installed by the session. `user_input()` suspends the run until the operator types an answer, and returns the answer text together with an availability flag. `ui()` returns a fresh snapshot of host state on every call; its `selected_model` field names the model currently selected in the interface, so an agent that re-reads it each turn follows the operator's menu choice. -Three calls do not exist in an agent: `call`, `fanout`, and `jump`. They are absent, not stubbed. An agent that calls one fails on an undefined global. +Everything else is the prompt language, exactly as the Prompt Language set teaches it: `models.infer` and `models.loop` run model rounds, `tools.add` brings tools into scope, `store` reads and writes files, `var` holds per-run state, and `log` records messages in the event stream. ## The moving parts -Two Rust crates carry the agent surface. `promptforge-agent` is the agent executor that runs your program. `promptforge-lua` is the Lua host runtime your program calls into. The workshop's built-in chat is no longer an agent program: it is an embedded Markdown prompt on the unified document runtime. Save your own program as `chat.lua` in the agents directory and it shadows the built-in, so your agent can take over the chat role. +Two crates carry an agent run. `workshop-sessions` owns discovery, launch, and the session extras: the input broker behind `user_input()`, the `ui()` snapshot, and the persisting event log. `promptforge-api` is the unified runtime that parses and runs the prompt itself. The final chapter of this set walks through the built-in chat program, the one agent every install already has. --- @@ -473,7 +491,7 @@ Every tool call is raced against the cancel signal. On cancel, the tool future i # The full loop -This chapter assembles the complete agent: a chat surface written as one `.lua` program. The workshop's built-in chat is an embedded Markdown prompt on the unified document runtime, but a program saved as `chat.lua` in the agents directory shadows it, so the chat you already use is a role your own agent can take. Walk through that program turn by turn, because everything you have learned so far shows up in it, working together. +This chapter assembles the complete agent: the built-in chat itself, one Markdown prompt embedded in the Workshop. A prompt saved as `chat.md` in the agents directory shadows it, so the chat you already use is a role your own agent can take. Walk through that program turn by turn, because the whole session surface shows up in it, working together. ## A chat agent @@ -481,45 +499,59 @@ A chat agent is a transparent pass-through. It advertises no tools and sets no s ## One turn -The agent is an infinite loop. Each turn does the same five things, in order. +The agent is one infinite loop in a single Lua block. Each turn does the same four things, in order. -1. Request the operator's next message by invoking the `user_input` tool through `tools.call`. -2. Read the event log with `runtime.events()`. -3. Build the model's message list from the log: map each `user_message` event to `role = 'user'` and each `agent_message` event to `role = 'assistant'`, reading the text from `event.content`. -4. Read the operator's selected model from the `ui()` snapshot's `selected_model` field. -5. Call `models.chat` under `pcall` with that model, then loop back to step 1. +1. Call `user_input()` to request the operator's next message, and return from the program when input is no longer available. +2. Append the operator's message to the retained history list. +3. Read the operator's selected model from the `ui()` snapshot's `selected_model` field. +4. Run `models.loop` over the history under that model, wrapped in `pcall`, then loop back to step 1. ## The full program -````lua +````markdown +--- +name: chat +description: The built-in Workshop chat agent on the unified runtime. +promptforge: 0 +--- + +# Chat + +The built-in chat agent: a transparent pass-through between the operator +and the selected model. The message list is an explicit Lua value retained +across turns; the model is re-read from the host snapshot every turn, so a +menu selection change takes effect on the next turn. + +## Conversation + +```lua +local history = messages.new() while true do - tools.call('user_input', {}) - local events = runtime.events() - local messages = {} - for i = 1, #events do - local event = events[i] - if event.kind == 'user_message' then - messages[#messages + 1] = { role = 'user', content = event.content } - elseif event.kind == 'agent_message' then - messages[#messages + 1] = { role = 'assistant', content = event.content } + local text, available = user_input() + if not available then + return + end + history:user(text) + local selected = ui().selected_model + if selected then + pcall(function() return models.loop(models.get(selected), history) end) end - end - pcall(models.chat, messages, { model = ui().selected_model }) end +``` ```` -This is the whole chat surface. Every line is a call you already know. +This is the whole chat surface. Work through the lines. `messages.new()` builds the empty conversation list once, before the loop starts. `user_input()` suspends the run until the operator answers, and returns the answer text together with an availability flag; when the flag reads false, the program returns instead of spinning on a dead session. `history:user(text)` appends the operator's message to the list. `ui().selected_model` reads the interface's current model selection, and `models.get(selected)` resolves that selection to a bound handle. `models.loop(handle, history)` runs the model round over the list, and the reply is appended to that same list as the final record, so the list the program passed in comes back one turn longer. -## Why the log is the state +## Why the list is the state -Notice what the program does not do: it never stores the conversation in a variable. Every turn rebuilds its message list from the event log instead of holding state in the program. The `user_input` call asks the operator for the next message, and that message arrives in the log as a `user_message` event, where the next rebuild picks it up. The agent's own replies sit in the log as `agent_message` events, and the same rebuild maps them to `assistant` messages. +Notice what the program never does: rebuild the conversation. The list is created once and retained across turns, and both sides accumulate in it - the program appends each operator message with `history:user(text)`, and `models.loop` appends each assistant reply as it completes. The next turn's model round therefore sees the whole conversation, and the program never copies, re-derives, or re-reads anything. -This is what makes the agent restartable. A relaunch over retained or reloaded history resumes the conversation exactly where it stood, because the whole conversation is in the log and the program rebuilds from it on every turn. A turn-cancel or a restart loses nothing. +Because the model is re-read from the `ui()` snapshot on every turn, never captured once before the loop, a menu selection change takes effect on the very next turn. ## Why pcall wraps the model call -The loop runs `models.chat` under `pcall` because the current chat survives transport errors, and so must this one. A failed call does not kill the agent. The session surfaces the failure to the operator, and the loop returns to `user_input` for the next turn. +The loop runs `models.loop` under `pcall` because chat survives transport errors, and so must this program. A failed round does not kill the agent. The session surfaces the failure to the operator, and the loop returns to `user_input()` for the next turn. ## Grow from here -Start from this program and add one capability at a time. Advertise a tool with `opts.tools` and answer the requested calls. Save notes with `store.write`. Keep a counter in `var`. The loop does not change. The turns just do more. +Start from this program and add one capability at a time. Bring a tool into scope with `tools.add` before the loop call and the model can ask for it. Save notes with `store.write`. Keep a counter in `var`. The loop does not change. The turns just do more. diff --git a/guide/promptforge-workshop-guide.md b/guide/promptforge-workshop-guide.md index 1298ad92a..640213738 100644 --- a/guide/promptforge-workshop-guide.md +++ b/guide/promptforge-workshop-guide.md @@ -77,7 +77,7 @@ The keys you are most likely to set: - `gateway.api_key` supplies the bearer key for the gateway API. An empty key sends no `Authorization` header, which is right for a gateway running with authentication disabled. - `server.bind` is honored only by the standalone `workshop-server` binary. The desktop application owns its listener and always binds `127.0.0.1` on an OS-assigned port. - `server.state_dir` chooses where the Workshop keeps persistent state. Agent session event logs live under `state_dir/sessions/`, and the per-profile model memory is written there. It defaults to the config file's own directory. -- `agents.path` chooses which directory of `.lua` agent programs is launchable. The default is `agents/` beside the config file. A missing directory offers no agents; that is a state, not an error. +- `agents.path` chooses which directory of `.md` agent prompts is launchable. The default is `agents/` beside the config file. A missing directory offers no agents; that is a state, not an error. String values support `${VAR}` environment interpolation, so you can keep secrets out of the file. A literal dollar sign is written `$$`. An unset variable interpolates to the empty string instead of failing startup. @@ -379,7 +379,7 @@ You have a model selected and chat is ready. This chapter teaches you the chat s The Agent Session panel on the right side of the window is where you talk to the selected model. Chat always runs as a live agent session, not a one-shot buffered request. Every reply streams through the open session, which opens instantly and stays open for the whole session. -The default chat is a transparent pass-through with no added system prompt and no tools. Your messages go to the model currently selected in the interface. A fresh install always offers this working built-in chat agent, even when there is no agents directory at all. Later you can add your own agents by dropping `.lua` files into the agents directory; each file appears as a launchable agent under its file-stem name in a sorted list, and a newly added agent file shows up in the agent list on the next connect, without a restart. Placing a `chat.lua` file in the agents directory shadows the built-in one, so you can replace the default chat with your own program. An existing `chat.lua` that cannot be read surfaces its error instead of silently serving the embedded source. +The default chat is a transparent pass-through with no added system prompt and no tools. Your messages go to the model currently selected in the interface. A fresh install always offers this working built-in chat agent, even when there is no agents directory at all. Later you can add your own agents by dropping `.md` prompt files into the agents directory; each file appears as a launchable agent under its file-stem name in a sorted list, and a newly added agent file shows up in the agent list on the next connect, without a restart. Placing a `chat.md` file in the agents directory shadows the built-in one, so you can replace the default chat with your own prompt. An existing `chat.md` that cannot be read surfaces its error instead of silently serving the embedded source. To send your first message: diff --git a/guide/src/agent/01-agent-programs.md b/guide/src/agent/01-agent-programs.md index a08512a82..e24e7ffd5 100644 --- a/guide/src/agent/01-agent-programs.md +++ b/guide/src/agent/01-agent-programs.md @@ -1,36 +1,53 @@ # Agent programs -This chapter teaches you what an agent program is, the file you write, and how the host runs it. Learn it first, because everything an agent does - talking to a model, calling a tool, reading what happened - is a call made from this one file. +This chapter teaches you what an agent is, the file you write, and how the Workshop runs it. Learn it first, because an agent is an ordinary PromptForge prompt document: everything the prompt language gives a prompt - sections, Lua blocks, model rounds, tools, the store - an agent has too. What makes it an agent is only where the file lives and who is listening. ## Write the smallest working agent -````lua +````markdown +--- +name: hello +description: The smallest working agent. +promptforge: 0 +--- + +# Hello + +## Speak + +```lua log('hello from my agent') +``` ```` -Save that one line in a file named `hello.lua`. The file is the whole agent. When the host runs it, the `log` call records the message `hello from my agent` in the run's event stream, and the program runs to its end. +Save that file as `hello.md` in the agents directory. The file is the whole agent: frontmatter that makes it a prompt, one title, one section, one Lua block. There is no manifest, no registration step, and no second file. When the host runs it, the `log` call records the message `hello from my agent` in the run's event stream, and the prompt runs to its end. -An agent is one `.lua` program. There is no manifest, no registration step, and no second file. The program you save is the program the host runs. +## How the Workshop runs an agent -## How the host runs the program +The Workshop discovers agents by reading the agents directory: every `.md` file there is a launchable agent, listed under its file-stem name in a sorted list. Discovery reads the directory per request, so a file you add shows up in the agent list on the next connect, with no restart. A missing or unreadable directory is a state, not an error: the list simply offers the built-in chat alone. -The host compiles your file as Lua 5.5 and runs it as a single long-running Lua coroutine. One run is one coroutine, driven from the first line to the end of the program. +Launching an agent parses the file as a PromptForge prompt and runs it on the unified document runtime, the same runtime that runs every other prompt. One launch is one prompt run: sections walk in order, Lua blocks suspend on host calls and resume with their answers, and the run ends when the document ends - or when the operator cancels it. -Your program keeps its own local state across the whole session. A local variable you set early is still there at the end, because the same coroutine runs every line. +The directory itself is a configuration value: `agents.path` in `workshop.toml`. The default is `agents/` beside the config file. ## The agent's name -The agent's name is the `.lua` file stem. Save the program as `hello.lua` and the agent's name is `hello`. Agents have no sections, so that name is the whole identity. +The agent's name is the `.md` file stem. Save the prompt as `hello.md` and the agent's name is `hello`. Discovery yields bare stems only, so a launch request can never be coaxed into naming a path. -Every event your agent emits carries the agent's name as its section label. The workshop UI and the event log both key on that name, so the name in the file stem is the name you see everywhere the run leaves a trace. +The name follows the run everywhere it leaves a trace: the agent list, the session panel, and the persisted event log all key on it. -## The host surface +## The built-in chat and the shadow -Your program reaches the host through a shared set of calls. `models.infer` runs one model completion. `tools.call` dispatches a tool. `store` reads and writes files. `var` holds per-run state. `log` records a message in the event stream. Cooperative cancellation lets the host stop the run. +A fresh install always offers a working chat agent, even when there is no agents directory at all. The built-in `chat` is a Markdown prompt embedded in the Workshop at compile time, and discovery always lists it. -Three calls do not exist in an agent: `call`, `fanout`, and `jump`. They are absent, not stubbed. An agent that calls one fails on an undefined global. +Save your own prompt as `chat.md` in the agents directory and it shadows the embedded source: the list still shows one `chat`, but launching it runs your file. That is how your own agent takes over the chat role. An existing `chat.md` that cannot be read surfaces its error instead of silently serving the embedded source. -## The moving parts +## The session surface -Two Rust crates carry the agent surface. `promptforge-agent` is the agent executor that runs your program. `promptforge-lua` is the Lua host runtime your program calls into. The workshop's built-in chat is no longer an agent program: it is an embedded Markdown prompt on the unified document runtime. Save your own program as `chat.lua` in the agents directory and it shadows the built-in, so your agent can take over the chat role. +An agent prompt runs with two extras an unattached prompt does not have, both installed by the session. `user_input()` suspends the run until the operator types an answer, and returns the answer text together with an availability flag. `ui()` returns a fresh snapshot of host state on every call; its `selected_model` field names the model currently selected in the interface, so an agent that re-reads it each turn follows the operator's menu choice. + +Everything else is the prompt language, exactly as the Prompt Language set teaches it: `models.infer` and `models.loop` run model rounds, `tools.add` brings tools into scope, `store` reads and writes files, `var` holds per-run state, and `log` records messages in the event stream. + +## The moving parts +Two crates carry an agent run. `workshop-sessions` owns discovery, launch, and the session extras: the input broker behind `user_input()`, the `ui()` snapshot, and the persisting event log. `promptforge-api` is the unified runtime that parses and runs the prompt itself. The final chapter of this set walks through the built-in chat program, the one agent every install already has. diff --git a/guide/src/agent/10-the-full-loop.md b/guide/src/agent/10-the-full-loop.md index 9ed47aaf7..118da5601 100644 --- a/guide/src/agent/10-the-full-loop.md +++ b/guide/src/agent/10-the-full-loop.md @@ -1,6 +1,6 @@ # The full loop -This chapter assembles the complete agent: a chat surface written as one `.lua` program. The workshop's built-in chat is an embedded Markdown prompt on the unified document runtime, but a program saved as `chat.lua` in the agents directory shadows it, so the chat you already use is a role your own agent can take. Walk through that program turn by turn, because everything you have learned so far shows up in it, working together. +This chapter assembles the complete agent: the built-in chat itself, one Markdown prompt embedded in the Workshop. A prompt saved as `chat.md` in the agents directory shadows it, so the chat you already use is a role your own agent can take. Walk through that program turn by turn, because the whole session surface shows up in it, working together. ## A chat agent @@ -8,45 +8,59 @@ A chat agent is a transparent pass-through. It advertises no tools and sets no s ## One turn -The agent is an infinite loop. Each turn does the same five things, in order. +The agent is one infinite loop in a single Lua block. Each turn does the same four things, in order. -1. Request the operator's next message by invoking the `user_input` tool through `tools.call`. -2. Read the event log with `runtime.events()`. -3. Build the model's message list from the log: map each `user_message` event to `role = 'user'` and each `agent_message` event to `role = 'assistant'`, reading the text from `event.content`. -4. Read the operator's selected model from the `ui()` snapshot's `selected_model` field. -5. Call `models.chat` under `pcall` with that model, then loop back to step 1. +1. Call `user_input()` to request the operator's next message, and return from the program when input is no longer available. +2. Append the operator's message to the retained history list. +3. Read the operator's selected model from the `ui()` snapshot's `selected_model` field. +4. Run `models.loop` over the history under that model, wrapped in `pcall`, then loop back to step 1. ## The full program -````lua +````markdown +--- +name: chat +description: The built-in Workshop chat agent on the unified runtime. +promptforge: 0 +--- + +# Chat + +The built-in chat agent: a transparent pass-through between the operator +and the selected model. The message list is an explicit Lua value retained +across turns; the model is re-read from the host snapshot every turn, so a +menu selection change takes effect on the next turn. + +## Conversation + +```lua +local history = messages.new() while true do - tools.call('user_input', {}) - local events = runtime.events() - local messages = {} - for i = 1, #events do - local event = events[i] - if event.kind == 'user_message' then - messages[#messages + 1] = { role = 'user', content = event.content } - elseif event.kind == 'agent_message' then - messages[#messages + 1] = { role = 'assistant', content = event.content } + local text, available = user_input() + if not available then + return + end + history:user(text) + local selected = ui().selected_model + if selected then + pcall(function() return models.loop(models.get(selected), history) end) end - end - pcall(models.chat, messages, { model = ui().selected_model }) end +``` ```` -This is the whole chat surface. Every line is a call you already know. +This is the whole chat surface. Work through the lines. `messages.new()` builds the empty conversation list once, before the loop starts. `user_input()` suspends the run until the operator answers, and returns the answer text together with an availability flag; when the flag reads false, the program returns instead of spinning on a dead session. `history:user(text)` appends the operator's message to the list. `ui().selected_model` reads the interface's current model selection, and `models.get(selected)` resolves that selection to a bound handle. `models.loop(handle, history)` runs the model round over the list, and the reply is appended to that same list as the final record, so the list the program passed in comes back one turn longer. -## Why the log is the state +## Why the list is the state -Notice what the program does not do: it never stores the conversation in a variable. Every turn rebuilds its message list from the event log instead of holding state in the program. The `user_input` call asks the operator for the next message, and that message arrives in the log as a `user_message` event, where the next rebuild picks it up. The agent's own replies sit in the log as `agent_message` events, and the same rebuild maps them to `assistant` messages. +Notice what the program never does: rebuild the conversation. The list is created once and retained across turns, and both sides accumulate in it - the program appends each operator message with `history:user(text)`, and `models.loop` appends each assistant reply as it completes. The next turn's model round therefore sees the whole conversation, and the program never copies, re-derives, or re-reads anything. -This is what makes the agent restartable. A relaunch over retained or reloaded history resumes the conversation exactly where it stood, because the whole conversation is in the log and the program rebuilds from it on every turn. A turn-cancel or a restart loses nothing. +Because the model is re-read from the `ui()` snapshot on every turn, never captured once before the loop, a menu selection change takes effect on the very next turn. ## Why pcall wraps the model call -The loop runs `models.chat` under `pcall` because the current chat survives transport errors, and so must this one. A failed call does not kill the agent. The session surfaces the failure to the operator, and the loop returns to `user_input` for the next turn. +The loop runs `models.loop` under `pcall` because chat survives transport errors, and so must this program. A failed round does not kill the agent. The session surfaces the failure to the operator, and the loop returns to `user_input()` for the next turn. ## Grow from here -Start from this program and add one capability at a time. Advertise a tool with `opts.tools` and answer the requested calls. Save notes with `store.write`. Keep a counter in `var`. The loop does not change. The turns just do more. +Start from this program and add one capability at a time. Bring a tool into scope with `tools.add` before the loop call and the model can ask for it. Save notes with `store.write`. Keep a counter in `var`. The loop does not change. The turns just do more. diff --git a/guide/src/introduction.md b/guide/src/introduction.md index b189a0d88..51b5b2ad5 100644 --- a/guide/src/introduction.md +++ b/guide/src/introduction.md @@ -30,5 +30,5 @@ If you operate the gateway, read [the Gateway set](gateway/index.md). It teaches If you write prompts, read [the Prompt Language set](language/index.md). It teaches the .md prompt syntax: frontmatter, sections and blocks, Lua globals, prose substitution, models, tools, control flow, and fanout. -If you write agent programs, read [the Agent Programs set](agent/index.md). It teaches the .lua host surface: the agent loop, chat rounds, tool calls, the event log, host state, the sandbox, and the full loop. +If you write agent programs, read [the Agent Programs set](agent/index.md). It teaches the .md agent surface: the agent loop, chat rounds, tool calls, the event log, host state, the sandbox, and the full loop. diff --git a/guide/src/workshop/01-application.md b/guide/src/workshop/01-application.md index c75fa28c1..9880f29d3 100644 --- a/guide/src/workshop/01-application.md +++ b/guide/src/workshop/01-application.md @@ -73,7 +73,7 @@ The keys you are most likely to set: - `gateway.api_key` supplies the bearer key for the gateway API. An empty key sends no `Authorization` header, which is right for a gateway running with authentication disabled. - `server.bind` is honored only by the standalone `workshop-server` binary. The desktop application owns its listener and always binds `127.0.0.1` on an OS-assigned port. - `server.state_dir` chooses where the Workshop keeps persistent state. Agent session event logs live under `state_dir/sessions/`, and the per-profile model memory is written there. It defaults to the config file's own directory. -- `agents.path` chooses which directory of `.lua` agent programs is launchable. The default is `agents/` beside the config file. A missing directory offers no agents; that is a state, not an error. +- `agents.path` chooses which directory of `.md` agent prompts is launchable. The default is `agents/` beside the config file. A missing directory offers no agents; that is a state, not an error. String values support `${VAR}` environment interpolation, so you can keep secrets out of the file. A literal dollar sign is written `$$`. An unset variable interpolates to the empty string instead of failing startup. diff --git a/guide/src/workshop/06-chat.md b/guide/src/workshop/06-chat.md index aa804ed2a..7361a27f9 100644 --- a/guide/src/workshop/06-chat.md +++ b/guide/src/workshop/06-chat.md @@ -6,7 +6,7 @@ You have a model selected and chat is ready. This chapter teaches you the chat s The Agent Session panel on the right side of the window is where you talk to the selected model. Chat always runs as a live agent session, not a one-shot buffered request. Every reply streams through the open session, which opens instantly and stays open for the whole session. -The default chat is a transparent pass-through with no added system prompt and no tools. Your messages go to the model currently selected in the interface. A fresh install always offers this working built-in chat agent, even when there is no agents directory at all. Later you can add your own agents by dropping `.lua` files into the agents directory; each file appears as a launchable agent under its file-stem name in a sorted list, and a newly added agent file shows up in the agent list on the next connect, without a restart. Placing a `chat.lua` file in the agents directory shadows the built-in one, so you can replace the default chat with your own program. An existing `chat.lua` that cannot be read surfaces its error instead of silently serving the embedded source. +The default chat is a transparent pass-through with no added system prompt and no tools. Your messages go to the model currently selected in the interface. A fresh install always offers this working built-in chat agent, even when there is no agents directory at all. Later you can add your own agents by dropping `.md` prompt files into the agents directory; each file appears as a launchable agent under its file-stem name in a sorted list, and a newly added agent file shows up in the agent list on the next connect, without a restart. Placing a `chat.md` file in the agents directory shadows the built-in one, so you can replace the default chat with your own prompt. An existing `chat.md` that cannot be read surfaces its error instead of silently serving the embedded source. To send your first message: diff --git a/tools/document.md b/tools/document.md index 2734f2730..9cdd033eb 100644 --- a/tools/document.md +++ b/tools/document.md @@ -120,7 +120,7 @@ Template: the Cookbook. Group chapters by operator goal. Audience: the prompt author. -Targets: `crates/promptforge-parser/`, `crates/promptforge-core/`, `prompts/`, `README.md`. +Targets: `crates/promptforge-parser/`, `crates/promptforge-api/`, `prompts/`, `README.md`. Extract: the .md prompt syntax. Frontmatter. Sections. Lazy prose. Lua blocks. Tool and model binding. models.infer and models.loop. Message builders. The store. var. call. fanout. jump. Noise: the Rust API, gateway operation. Output: `guide/src/language/`. diff --git a/vibe/2026-09-12-5-one-door-promptforge-api.md b/vibe/2026-09-12-5-one-door-promptforge-api.md new file mode 100644 index 000000000..f225500af --- /dev/null +++ b/vibe/2026-09-12-5-one-door-promptforge-api.md @@ -0,0 +1,591 @@ +--- +name: One-door promptforge-api +overview: Remove the standalone .lua agent program path and the promptforge-agent crate, rename promptforge-core to promptforge-api as the single crate outside products may consume, sink cross-product vocabulary (observation, events, cancellation, model/wire types, the Tool contract and ToolCatalog) into shared-promptforge-api instead of re-exporting it, and enforce the product boundary in the existing xtask tidy harness run by cargo test. +todos: + - id: remove-lua-agent-path + content: "Remove .lua agent path in workshop-sessions: discovery to .md, delete launch_lua, local run error type, audit UserInputTool, fix tests and docs" + status: pending + - id: delete-agent-crate + content: Delete crates/promptforge-agent and its Cargo/facade edges + status: pending + - id: fix-guides + content: Rewrite agent guides and READMEs for .md directory agents + status: pending + - id: rename-core-support + content: Rename promptforge-core-support to shared-promptforge-api; sink model vocabulary and the entire tool vocabulary into it + status: pending + - id: rename-core-to-api + content: Rename promptforge-core to promptforge-api, retire the promptforge facade crate, make picker optional and vfs defaulted in the run API + status: pending + - id: delete-integration-tests + content: Delete crates/product-integration-tests/ and all references to it + status: pending + - id: migrate-consumers + content: Migrate workshop-sessions, workshop-gateway, workshop-protocol to promptforge-api + shared-promptforge-api; reimplement gateway progress + status: pending + - id: enforce-boundary + content: Add product-boundary check to xtask tidy with test wrapper + status: pending + - id: verify + content: Run full verification suite and metadata sweep + status: pending +isProject: false +--- + +# One-Door promptforge-api + + + +## Product Requirements + +The PromptForge product exposes too many crates to outside consumers. The executor and every type it touches should be reachable through one crate, with shared vocabulary in a shared crate and an enforced boundary that prevents regressions. + +- Problem and users: + - `workshop-sessions` depends directly on seven promptforge substrate crates (`promptforge-core`, `promptforge-core-support`, `promptforge-model-client`, `promptforge-tool-picker`, `promptforge-vfs`, `promptforge-tools`, `promptforge-agent`). `workshop-gateway` depends on `promptforge-core-support` and `promptforge-model-client`. `workshop-protocol` depends on `promptforge-core-support`. `workshop-server` has zero production promptforge deps (only dev-deps for integration tests). Future third-party hosts embedding the PromptForge runtime need a single, stable API surface. + - The standalone `.lua` agent program path is dead; the unified Markdown prompt runtime replaced it. The code path, discovery logic, guides, and the `promptforge-agent` crate remain as vestigial weight. + - `product-integration-tests` is a dead crate (one `#[ignore]`d live-inference smoke test, not in CI, never run routinely). +- Goals: + - Establish `promptforge-api` (renamed from `promptforge-core`) as the only `promptforge-*` crate outside products may depend on. + - Establish `shared-promptforge-api` (renamed from `promptforge-core-support`, absorbing tool and model vocabulary) as the shared vocabulary crate both promptforge and workshop depend on directly. + - Remove the `.lua` agent program path entirely: discovery, launch, error types, guides, and the `promptforge-agent` crate. Directory agents become `.md` prompts. + - Enforce the one-door boundary in the build via the existing `xtask` tidy harness (`cargo test -p xtask`). +- Non-goals: + - Do not merge internal substrate crates into one binary crate. Internal crate boundaries stay for Cargo-enforced layering and parallel compilation. + - Do not share types with gateway crates. Gateway deliberately owns its own wire vocabulary and has zero promptforge edges today. + - Do not implement host-installed tool groups or a global tool namespace (future work). + - Do not change the executor's runtime behavior, prompt language semantics, or model-loop protocol. + - Do not change Bashkit integration (future work; Bashkit is a `Tool` whose constructor takes a `VfsRef` clone and an optional command overlay registry). +- Success criteria: + - `workshop-sessions` depends on exactly `promptforge-api` plus `shared-promptforge-api` and no other `promptforge-*` crate. + - `workshop-gateway` depends on exactly `shared-promptforge-api` and no other `promptforge-*` crate. + - `workshop-protocol` depends on `shared-promptforge-api` (a `shared-*` crate, legal for any product). + - `workshop-server` dev-dependencies reference only `promptforge-api` and `shared-promptforge-api`. + - `cargo test -p xtask` fails if any crate outside the PromptForge product depends on a `promptforge-*` crate other than `promptforge-api`. + - No `.lua` agent program code, discovery, error type, or guide content remains outside historical `vibe/` docs. + - All workspace tests, clippy, and format checks pass. +- Constraints: + - Keep `promptforge: 0` throughout; never increment it. + - `shared-promptforge-api` must have zero product-crate dependencies (only third-party and other `shared-*` crates). + - AGENTS.md bars new topology checks without explicit operator approval. The operator explicitly directed this enforcement. + - The 500-line file ceiling applies to `xtask/src/tidy.rs` (currently 332 lines); split if the addition would exceed it. +- Open questions: + - None + +## Functional Specification + +Outside consumers see two crates: `promptforge-api` for the executor and `shared-promptforge-api` for vocabulary. Directory agents are `.md` prompts discovered under `agents.path`. The build enforces the boundary. + +- Actors and workflows: + - A host embedding PromptForge depends on `promptforge-api` for `run()`, `RunConfig`, `Prompt`, and re-exported parser/client types, and on `shared-promptforge-api` for `Observer`, `CancelHandle`, `Tool`, `ToolCatalog`, model vocabulary, and event/metrics types. + - Workshop discovers `.md` file stems under `agents.path`. A directory `chat.md` shadows the embedded built-in (same shadowing semantics as the former `.lua` path, new extension). Every directory agent launches through `promptforge_api::run` on the unified Markdown runtime. + - `RunConfig` defaults the store handle to `promptforge_vfs::empty()` internally; hosts that do not seed the store omit it. `ResolutionContext` accepts an optional tool picker; capability-free agents pass `None`. +- Inputs and outputs: + - `promptforge-api::run(&Prompt, &str, ResolutionContext, RunConfig)` - the `&vfs` parameter is absorbed into `RunConfig` with a default. + - `shared-promptforge-api` carries every symbol in the symbol table below. No other public items. +- States and validation: + - The xtask boundary check validates all dependency kinds (normal, dev, build, target-specific) across every workspace manifest on every `cargo test -p xtask` run. +- Errors and recovery: + - A session-local `AgentRunError` in `workshop-sessions` replaces `promptforge_agent::AgentError`, mapping `RunErrorKind::Cancelled` to interrupted and all other failures to a message-bearing variant. +- Security and privacy behavior: + - No change to trust envelopes, untrusted-content guards, or credential handling. +- Acceptance criteria: + - `cargo metadata --locked` shows no outside-product edge to any `promptforge-*` crate except `promptforge-api`. + - Directory agent discovery finds `.md` stems only, shadows the built-in via `chat.md`, and rejects unreadable shadows with an error. No `.lua` files are discovered, launched, or mentioned in any error message. + - The `subscribe_progress` gateway integration in `workshop-gateway` works over the workshop's own gateway client, not through `promptforge-model-client`. + + + + + +## Technical Design + +Five slices, strictly ordered after the first two (which are independent). Slice 1 removes the `.lua` path. Slice 2 renames `promptforge-core-support` to `shared-promptforge-api` and sinks vocabulary. Slice 3 renames `promptforge-core` to `promptforge-api` and fixes API signatures. Slice 4 migrates consumers. Slice 5 adds xtask enforcement. + +- Architecture: + +```mermaid +flowchart TD + subgraph consumers [Outside consumers] + workshopSessions[workshop-sessions] + workshopGateway[workshop-gateway] + workshopProtocol[workshop-protocol] + workshopServer["workshop-server (dev-deps only)"] + end + + subgraph oneDoor [The one door] + api[promptforge-api] + end + + subgraph internal [Internal substrate] + parser[promptforge-parser] + lua[promptforge-lua] + webSearch[promptforge-web-search] + others["promptforge-store + promptforge-model-client + promptforge-tool-picker + promptforge-vfs + promptforge-webfetch"] + end + + subgraph shared [Shared vocabulary] + sharedPf[shared-promptforge-api] + sharedVfs[shared-vfs] + sharedProgress[shared-progress] + end + + gatewayGroup["gateway-*"] + + workshopSessions --> api + workshopSessions --> sharedPf + workshopGateway --> sharedPf + workshopProtocol --> sharedPf + workshopServer -.->|"dev-deps only"| api + + api --> parser + api --> lua + api --> others + api --> webSearch + + parser --> sharedPf + lua --> sharedPf + others --> sharedPf + others --> sharedVfs + webSearch --> sharedPf + + gatewayGroup -.->|"forbidden"| api + gatewayGroup -.->|"forbidden"| internal +``` + +- Modules and interfaces: + - **Slice 1 - Remove `.lua` agent path:** + - [crates/workshop-sessions/src/agents.rs](crates/workshop-sessions/src/agents.rs): delete `AgentSource::Lua`; discovery lists `.md` file stems; `chat.md` shadowing replaces `chat.lua` shadowing. Update discovery tests. + - [crates/workshop-sessions/src/agents/supervisor/effects.rs](crates/workshop-sessions/src/agents/supervisor/effects.rs): delete `launch_lua` and the `AgentSource::Lua` arm. Introduce session-local `AgentRunError` mapping `RunErrorKind::Cancelled` to interrupted. Delete the `promptforge_agent` import. + - [crates/workshop-sessions/src/agents/supervisor/events.rs](crates/workshop-sessions/src/agents/supervisor/events.rs) and [crates/workshop-sessions/tests/it/chat_gate.rs](crates/workshop-sessions/tests/it/chat_gate.rs): replace `AgentError` with the local error type. + - [crates/workshop-sessions/src/input/tool.rs](crates/workshop-sessions/src/input/tool.rs): audit `UserInputTool` - if it only served `.lua` agents, delete it; if still wired, replace with core `InputTool`. Fix docs either way. + - [crates/workshop-sessions/src/config.rs](crates/workshop-sessions/src/config.rs): update doc comments from `.lua` to `.md`. + - Delete `crates/promptforge-agent/` entirely (src, Cargo.toml, AGENTS.md). Remove from root `Cargo.toml` workspace dependencies. + - [crates/promptforge/src/lib.rs](crates/promptforge/src/lib.rs): delete the `agent` module and its Cargo dependency. + - `LuaProgram` stays: it is the compiled-chunk type behind `Block::Lua`, not the standalone runner. + - **Slice 2 - `promptforge-core-support` becomes `shared-promptforge-api`:** + - `git mv crates/promptforge-core-support crates/shared-promptforge-api`; rename the package; update every dependent manifest (`promptforge-parser`, `promptforge-lua`, `promptforge-model-client`, `promptforge-core`, `workshop-protocol`, `workshop-gateway`, `workshop-sessions`) and the root `Cargo.toml` workspace entry. + - Sink only the host-facing model vocabulary from `promptforge-model-client` into a `models` module: `ModelId`, `ModelIdError`, `ModelCatalog`, `ModelCatalogError`, `ModelDescriptor`, `ThinkingMode`. Also move `StreamDelta` (the one wire type hosts name, for the `on_delta` callback). `promptforge-model-client` keeps everything else: transport (`GatewayClient`, `GatewayEndpoint`, `SecretString`, `SecretError`, `fetch_model_catalog`, `subscribe_progress`), binding machinery (`ModelBinding`/`ModelSet`/`ModelView`/`ModelBindOpts`/`ModelInvocation`, `CompletionError`/`CompletionErrorKind`), value types hosts never construct (`Temperature`, `TemperatureError`, `CompletionOptions`), and all wire types (`Message`, `ToolSchema`, `ToolSchemaError`, `ToolCall`, `ToolArguments`, `Completion`, `CompletionResult`). + - Sink the entire `promptforge-tools` crate as a `tools` module. The crate dissolves: delete `crates/promptforge-tools/` and remove it from the root `Cargo.toml` workspace dependencies. Its dependents (`promptforge-webfetch`, `promptforge-web-search`, `promptforge-lua`, `promptforge-core`) switch to `shared-promptforge-api`. `promptforge-tool-picker`'s private `ToolId` ([crates/promptforge-tool-picker/src/catalog.rs](crates/promptforge-tool-picker/src/catalog.rs) line 38) is a separate type and stays. + - Add `async-trait` and `serde_json` to `shared-promptforge-api`'s Cargo.toml (required by `Tool` trait and `Tool::call`/`ToolCallEvent::arguments`). + - Drop the deprecated free function `untrusted::wrap` (deprecated since 0.2.0; `GuardNonce::wrap` is the method). + - Update doc examples: `use promptforge_tools::...` becomes `use shared_promptforge_api::tools::...`; `use promptforge_model_client::model::...` becomes `use shared_promptforge_api::models::...`; etc. + - **Slice 3 - `promptforge-core` becomes `promptforge-api`:** + - `git mv crates/promptforge-core crates/promptforge-api`; rename the package; move integrator-facing metadata from the retiring facade. + - Delete `crates/promptforge/` (facade, AGENTS.md, README). + - Root `Cargo.toml`: replace `promptforge-core` and `promptforge` workspace entries with `promptforge-api`. + - `ResolutionContext` ([crates/promptforge-api/src/execute/gateway.rs](crates/promptforge-api/src/execute/gateway.rs)): make the tool picker optional so capability-free agents pass `None`. + - `RunConfig` ([crates/promptforge-api/src/execute/config.rs](crates/promptforge-api/src/execute/config.rs)): absorb the store handle as an optional entry defaulting to `promptforge_vfs::empty()`; `run()` drops its `&vfs` parameter. + - Re-export posture: only two re-export modules survive, both justified by `run()`'s signature orbit. `parser` (`Prompt`, `ParseError`, `Block`, `Section`, ...) because hosts call `Prompt::parse()`. `client` (`GatewayClient`, `GatewayEndpoint`, `SecretString`, `CompletionError`/`CompletionErrorKind`) because hosts pass `GatewayClient` to `RunConfig::client()`. Drop `tools`, `observe`, `model`, `store` re-export modules and root `CancelHandle` re-export - all now in `shared-promptforge-api` or reachable through it. `store` re-exports are dropped because the VFS handle moves into `RunConfig` with a default; hosts that seed/extract the store depend on `shared-vfs` directly (a `shared-*` crate, legal for any product). + - Mechanical: update `promptforge_core::` self-references in tests, benches, and doc comments. + - **Slice 4 - Migrate consumers:** + - [crates/workshop-sessions/Cargo.toml](crates/workshop-sessions/Cargo.toml): collapse the seven promptforge deps to two edges: `promptforge-api` and `shared-promptforge-api`. Rewrite all imports. + - [crates/workshop-gateway/Cargo.toml](crates/workshop-gateway/Cargo.toml): collapse `promptforge-core-support` and `promptforge-model-client` to one edge: `shared-promptforge-api`. Rewrite imports. + - [crates/workshop-protocol/Cargo.toml](crates/workshop-protocol/Cargo.toml): rename `promptforge-core-support` dependency to `shared-promptforge-api`; update imports. + - [crates/workshop-server/Cargo.toml](crates/workshop-server/Cargo.toml): update dev-dependencies to reference only `promptforge-api` and `shared-promptforge-api`. + - [crates/workshop-sessions/src/gateway_progress.rs](crates/workshop-sessions/src/gateway_progress.rs): reimplement `GET /admin/progress` subscription over the workshop's own gateway client instead of `promptforge_model_client::model::subscribe_progress`. + - Delete empty-`ToolPicker`, empty-`ToolCatalog`, and `vfs: VfsRef` field construction in `workshop-sessions/src/agents/supervisor/effects.rs` and `workshop-sessions/src/agents.rs`, using the Slice 3 defaults. + - Delete `crates/product-integration-tests/` entirely and remove it from the root `Cargo.toml` workspace dependencies. + - **Slice 5 - Enforce boundary:** + - Rename `crates/xtask/` to `crates/build-xtask/`: rename the directory, update the package name in its Cargo.toml, update the root `Cargo.toml` workspace dependency entry, update the `xtask` alias in `.cargo/config.toml` to point at the renamed crate, and update any CI or doc references. + - [crates/build-xtask/src/tidy.rs](crates/build-xtask/src/tidy.rs): add `product_boundary_violations` check wired into `all_violations` with a `#[test]` wrapper. If the file would exceed 500 lines, split a `product.rs` module first. + - Rules encoded across all dependency kinds: no outside crate may depend on `promptforge-*` except `promptforge-api`; codify the full AGENTS.md product-boundary matrix (promptforge/gateway/workshop isolation). + - Update [crates/promptforge-api/AGENTS.md](crates/promptforge-api/AGENTS.md) and root [AGENTS.md](AGENTS.md) to name the one-door rule. + +- File and public API changes: + - Crates deleted: `promptforge-agent`, `promptforge-tools`, `promptforge` (facade), `product-integration-tests`. + - Crates renamed: `promptforge-core-support` to `shared-promptforge-api`, `promptforge-core` to `promptforge-api`. + - `run()` signature loses the `&vfs` parameter (absorbed into `RunConfig` with a default). + - `ResolutionContext` gains an optional picker path. + - Guides rewritten: [guide/promptforge-agent-guide.md](guide/promptforge-agent-guide.md), [guide/src/agent/01-agent-programs.md](guide/src/agent/01-agent-programs.md), [crates/workshop-sessions/README.md](crates/workshop-sessions/README.md). + +- Data, persistence, failure, security, and privacy constraints: + - No persistence, protocol, or security changes. Trust envelopes, untrusted-content guards, and credential handling are unchanged. + - `workshop-sessions`'s event log format is unchanged; the `RuntimeEvent` JSONL schema is stable. + + + +### Complete symbol table for shared-promptforge-api + +31 public symbols. This table is the contract: any plan change that alters the crate's contents must update it. + +**`cancel` module (1 public symbol):** + +| Symbol | Kind | Description | +|---|---|---| +| `CancelHandle` | struct | Create, clone, pass to `RunConfig`, call `.cancel()` to stop a run | + +The 5 cancel helpers (`scope`, `maybe_scope`, `current`, `wait_cancelled`, `is_cancelled`) stay in the crate as `#[doc(hidden)]` exports - executor internals consumed only by `promptforge-core`, `promptforge-lua`, and `promptforge-agent`. No workshop crate calls them (verified by exhaustive grep). + +**`events` module (9 symbols):** + +| Symbol | Kind | Description | +|---|---|---| +| `EventLog` | trait | Read-side run history: append-only, indexed `len`/`get` | +| `RuntimeEvent` | struct | One durable serde record; one JSONL line | +| `RuntimeEventKind` | enum | `AssistantReply`, `AssistantToolCalls`, `ToolResult`, `Thinking`, `UserInput` | +| `ToolCallEvent` | struct | One model-requested tool call: id, name, raw arguments | +| `CallMetrics` | struct | Everything measured about one model call | +| `Usage` | struct | Token accounting | +| `LlamaTimings` | struct | llama.cpp `timings` | +| `VllmMetrics` | struct | vLLM per-request metrics | +| `ClientTiming` | struct | Client-clock timing | + +**`observe` module (3 symbols):** + +| Symbol | Kind | Description | +|---|---|---| +| `Observation` | enum | Report-only lifecycle vocabulary; `#[non_exhaustive]` | +| `Observer` | trait | Report-only sink with default-body content hooks | +| `NullObserver` | struct | The discarding observer | + +`detail` stays as a `#[doc(hidden)]` emit-site seam, not counted. + +**`models` module (6 symbols, sunk from promptforge-model-client):** + +| Symbol | Kind | Description | +|---|---|---| +| `ModelId` | struct | Stable model identity: server namespace plus name | +| `ModelIdError` | struct | Identity validation failure | +| `ModelCatalog` | struct | Collision-free live model set for one bind pass | +| `ModelCatalogError` | enum | Catalog build failure (`DuplicateId`) | +| `ModelDescriptor` | struct | One catalogued model: id, description, context window, thinking | +| `ThinkingMode` | enum | `Never`/`Always`/`Switchable` | + +**`wire` module (1 symbol):** + +| Symbol | Kind | Description | +|---|---|---| +| `StreamDelta` | enum | `Text`/`Reasoning` - the `on_delta` callback type | + +**`tools` module (11 symbols, sunk from promptforge-tools; the crate dissolves):** + +| Symbol | Kind | Description | +|---|---|---| +| `Tool` | trait | The host extension point | +| `ToolCatalog` | struct | Construction-validated tool registry | +| `ToolCatalogError` | enum | Catalog build failure with stable `kind()` | +| `ToolCatalogErrorKind` | enum | Classifier for `ToolCatalogError` | +| `ToolId` | struct | Stable tool identity | +| `ToolIdError` | struct | Identity validation failure with stable `kind()` | +| `ToolIdErrorKind` | enum | Classifier (`Empty`, `Separator`, `Control`) | +| `ToolOutput` | struct | Successful result carrying text and mandatory trust | +| `OutputTrust` | enum | `Trusted`/`Untrusted` | +| `ToolError` | struct | Narrow, model-safe tool failure | +| `ToolErrorKind` | enum | Classifier (`InvalidArguments`, `Backend`, `Transport`, `Cancelled`, `Other`) | + +**Deliberately excluded from the shared crate (stays in substrate crates, verified by grep):** + +- **Executor internals:** `scope`/`maybe_scope`/`current`/`wait_cancelled`/`is_cancelled` (cancel helpers, doc-hidden), `GuardNonce` (untrusted wrapping, doc-hidden), `detail` (observe emit-site constants, doc-hidden) +- **Binding machinery hosts never construct:** `Temperature`, `TemperatureError`, `CompletionOptions`, `ModelBinding`, `ModelSet`, `ModelView`, `ModelBindOpts`, `ModelInvocation`, `ModelResolver`, `ResolvedModel`, `PickerModelResolver` +- **Wire types only the executor names:** `Message`, `ToolSchema`, `ToolSchemaError`, `ToolCall`, `ToolArguments`, `Completion`, `CompletionResult` +- **Transport:** `GatewayClient`, `GatewayEndpoint`, `SecretString`, `SecretError`, `CompletionError`, `CompletionErrorKind`, `fetch_model_catalog`, `subscribe_progress` + +`untrusted` and `cancel` modules stay in the crate physically (they move with the rename) but their internal-only symbols are `#[doc(hidden)]`, not public API. + + + +## Testing Plan + +The existing test suite covers the executor, workshop-sessions, and xtask. This plan adds the boundary check and verifies nothing regresses. + +- Unit: + - Existing parser, Lua, core, model-client, projection, and input tests continue to pass under the renamed crates. + - Discovery tests in `workshop-sessions` updated: `.md` stems replace `.lua`, `chat.md` shadowing replaces `chat.lua`, unreadable-shadow tests preserved. + - New `product_boundary_violations` test in xtask. +- Integration and end-to-end: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server`. + - The `chat_gate` integration tests pass with the local `AgentRunError` replacing `AgentError`. + - The reimplemented `subscribe_progress` in `gateway_progress.rs` produces the same observable behavior. +- Regression, security, and performance: + - `cargo fmt --all --check` and `cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings` plus the workshop pair. + - `cargo metadata --locked` confirms no outside-product edges to `promptforge-*` except `promptforge-api`. + - Grep sweep: no `promptforge_agent`, `promptforge_core` (underscore form), `promptforge-core` (hyphen form in non-historical files), `product-integration-tests`, or `.lua agent` references outside `vibe/` docs. + - Doc examples compile: all moved types have updated `use` paths. +- Exit criteria: + - `cargo test -p build-xtask` green (boundary check). + - Full workspace test suite green. + - No `promptforge-tools`, `promptforge-agent`, or `product-integration-tests` crate directory exists. + - Historical `vibe/` plans are deliberately untouched and excluded from the sweep. + + + + + +## Decision Record + +- Decisions: + - **One-door API crate.** `promptforge-core` is renamed to `promptforge-api` and becomes the only `promptforge-*` crate outside products may depend on. The implementation moves untouched - no facade, no duplication. The operator directed: "I want the outside world to only see one promptforge crate, not promptforge-*." + - **Shared vocabulary crate.** `promptforge-core-support` is renamed to `shared-promptforge-api` and absorbs model vocabulary from `promptforge-model-client` and the entire tool contract from `promptforge-tools`. The operator directed: "Tool stuff like Tool, Tools, ToolCatalog, those need to be in shared-promptforge-api" and emphasized tools are the host integration surface, the price of admission. + - **`.lua` agent programs are dead.** All `.lua` discovery, launch, error types, guides, and the `promptforge-agent` crate are removed. Directory agents are `.md` prompts only - no `.lua` fallback, no compatibility shim, no deprecation period. The operator confirmed: "Yes .lua is dead, everything has to be removed, guides fixed, everything. The user can drop .md files in the agent.path instead" and later reinforced: "there will be no more .lua agents." + - **Reading 1 for internal crates.** The PromptForge product depends on no other product crates (only `shared-*` and third-party). Internal substrate crates stay as separate crates for Cargo-enforced layering. The operator chose: "Reading 1 is what I want." + - **Enforce with cargo test.** The boundary is enforced in the existing `xtask` tidy harness, which runs under `cargo test -p xtask`. The operator directed: "I want the structure enforced with cargo." + - **No re-exports for vocabulary.** Types that are cross-product vocabulary live in `shared-promptforge-api` and consumers name them there directly. Re-exports exist only for types that appear in `promptforge-api`'s own function signatures and still live in internal crates. The operator directed: "I dont want re-exports if they can be avoided." + - **API signature simplification.** `ResolutionContext` makes the tool picker optional. `RunConfig` absorbs the VFS handle with a default. These changes remove the need for outside consumers to construct empty pickers or name `promptforge-vfs`. + - **`promptforge-tools` dissolves entirely.** The crate is 100% vocabulary (`ids.rs`, `output.rs`, `registry.rs`); all of it sinks into `shared-promptforge-api::tools`. The crate directory and Cargo entry are deleted. + - **Delete `product-integration-tests`.** One `#[ignore]`d live-inference smoke test, not in CI, never run routinely. Dead weight. + - **Only host-facing symbols move to the shared crate.** Exhaustive grep confirmed: wire types (`Message`, `ToolCall`, `ToolSchema`, `Completion`, etc.) are named only by promptforge-internal crates, never by workshop. Cancel helpers (`scope`, `maybe_scope`, `current`, `wait_cancelled`, `is_cancelled`) are the same. `Temperature`/`CompletionOptions` are binding machinery hosts never construct (`ModelDescriptor::new` takes `ThinkingMode`, not `Temperature`). `GuardNonce` is executor-internal. All stay in their substrate crates as `#[doc(hidden)]` or private. Only `StreamDelta` crosses the host boundary (the `on_delta` callback). + - **Topology check is an approved exception.** AGENTS.md bars new topology checks without explicit operator approval. The operator explicitly directed this enforcement, extending an existing approved facility. +- Rejected alternatives: + - **Option B (full merge).** Folding all 12 substrate crates into one loses Cargo-enforced internal layering, compile parallelism, and forces `promptforge-tool-picker`'s tensor/FFI build on every consumer. Revisit only if the convention proves leaky. + - **`shared-promptforge` as the crate name.** The operator consistently used `shared-promptforge-api`; the shorter name was dropped. + - **Putting the execution API in `shared-promptforge`.** Violates the `shared-*` rule ("must not depend on any product crates") because the executor depends on parser, Lua, model-client, store, and tools. + - **Re-export modules for events/observe/cancel/tools.** The operator directed avoiding re-exports; sinking vocabulary to the shared crate is the strategy. +- Assumptions, risks, and notes: + - `promptforge-tool-picker`'s private `ToolId` in `catalog.rs` is a distinct type from the contract `ToolId` and stays in the picker. + - `workshop-sessions`'s `subscribe_progress` rewrite (Slice 4) is the highest-risk item: it reimplements a gateway SSE subscription over a different HTTP client. Observable behavior must be identical. + - `CompletionError`/`CompletionErrorKind` stay in `promptforge-model-client` because they wrap its internal transport error enum; they reach consumers through the `promptforge-api::client` re-export. + - `shared-promptforge-api` needs `async-trait` (for `Tool`) and `serde_json` (for `Tool::call` signature and `ToolCallEvent::arguments`) added as Cargo dependencies. + - Historical `vibe/` plans reference old crate names and are deliberately left untouched. + + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build` (default member is `gateway`; desktop app: `cargo build -p workshop`). +- Full test: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server`. +- Focused test command pattern: `cargo nextest run --locked -p []` for the step's primary crate (add `--all-features` when the crate has features). +- Component test command pattern: `cargo nextest run --locked -p [-p ...]` covering every crate the component touches. +- Clippy: `cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings` (workshop: `cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings`). +- Format: `cargo fmt --all --check`. +- Boundary check: `cargo test -p build-xtask` (also `cargo xtask tidy` via the `.cargo/config.toml` alias). +- Existing xtask tidy harness: [crates/build-xtask/src/tidy.rs](crates/build-xtask/src/tidy.rs) (332 lines) enforces workshop tier dependencies, file ceiling, and lint inheritance. Tier constants: VOCABULARY = workshop-protocol, workshop-registry, workshop-support; SERVICES = workshop-gateway, workshop-menu, workshop-status; FEATURES = workshop-sessions, workshop-workspace; SHELL = workshop-server. +- Post-decomposition workshop crate map (verified 2026-09-12): `workshop-sessions` has 7 promptforge edges (core, core-support, model-client, tool-picker, vfs, tools, agent). `workshop-gateway` has 2 (core-support, model-client). `workshop-protocol` has 1 (core-support). `workshop-server` has 0 production deps (7 dev-deps for integration tests). `workshop`, `workshop-menu`, `workshop-registry`, `workshop-status`, `workshop-support`, `workshop-workspace` have 0. +- Session runtime location: `crates/workshop-sessions/src/agents/` (formerly `workshop-server/src/session_agents/`). Embedded chat: `crates/workshop-sessions/agents/chat.md`. `AgentSource` in `crates/workshop-sessions/src/agents/session.rs`. +- Gateway crate survey (verified 2026-09-12): all 12 gateway-family crates have zero promptforge edges. Gateway deliberately keeps opaque JSON wire types per [crates/gateway-protocol/src/wire.rs](crates/gateway-protocol/src/wire.rs) line 1. +- Duplicate vocabulary inventory: `ThinkingMode` is variant-for-variant identical in `gateway-config` and `promptforge-model-client`. Gateway `ModelInfo`/`Model`/`ModelConfig` are richer supersets of `ModelDescriptor`. Gateway passes usage/timings as opaque JSON; promptforge has typed `Usage`/`LlamaTimings`/`VllmMetrics`/`ClientTiming`. Gateway's private `ParsedCall` mirrors `ToolCall`. + + + + + +## Execution Instructions + +Component order and placement reasons: + +1. `lua-path-removal` - pure deletion inside `workshop-sessions` plus the `promptforge-agent` crate; depends on nothing and unblocks consumer migration. +2. `shared-vocabulary` - independent of `lua-path-removal` (the two may be built jointly); placed second because `api-crate` cannot drop its re-export modules until the types live in the shared crate. +3. `api-crate` - depends on `shared-vocabulary`. +4. `consumer-migration` - depends on `lua-path-removal` (agent crate gone) and `api-crate` (renames and new signatures done). +5. `boundary-enforcement` - depends on `consumer-migration`; the check would fail on the old edges. +6. `verification` - depends on `boundary-enforcement`; runs last. + +Pieces within each component are sequential: each step's commit must compile and pass its tests against the previous step's tree. + + + +### Step 1: Remove .lua discovery and launch from workshop-sessions [completed] + +- Component: lua-path-removal +- Piece: sessions-lua-removal (sequential: variant removal, arm removal, and error type must compile in one commit) +- [crates/workshop-sessions/src/agents.rs](crates/workshop-sessions/src/agents.rs): delete `AgentSource::Lua`; discovery lists `.md` file stems only; `chat.md` shadowing replaces `chat.lua` shadowing; unreadable-shadow rejection preserved. +- [crates/workshop-sessions/src/agents/supervisor/effects.rs](crates/workshop-sessions/src/agents/supervisor/effects.rs): delete `launch_lua` and the `AgentSource::Lua` arm; delete the `promptforge_agent` import; introduce session-local `AgentRunError` mapping `RunErrorKind::Cancelled` to interrupted and all other failures to a message-bearing variant. +- [crates/workshop-sessions/src/agents/supervisor/events.rs](crates/workshop-sessions/src/agents/supervisor/events.rs) and [crates/workshop-sessions/tests/it/chat_gate.rs](crates/workshop-sessions/tests/it/chat_gate.rs): replace `AgentError` with `AgentRunError`. +- [crates/workshop-sessions/src/config.rs](crates/workshop-sessions/src/config.rs): update doc comments from `.lua` to `.md`. +- Tests in the same commit: updated discovery tests (`.md` stems, `chat.md` shadowing, unreadable shadow) and the `chat_gate` integration tests against `AgentRunError`. + + + + + +### Step 2: Audit UserInputTool [completed] + +- Component: lua-path-removal +- Piece: sessions-lua-removal +- [crates/workshop-sessions/src/input/tool.rs](crates/workshop-sessions/src/input/tool.rs): if `UserInputTool` only served `.lua` agents, delete it; if still wired, replace with the core `InputTool`. Fix docs either way. +- Tests in the same commit: input tool unit tests updated or deleted with the tool. + + + + + +### Step 3: Delete the promptforge-agent crate [completed] + +- Component: lua-path-removal +- Piece: agent-crate-deletion +- Delete `crates/promptforge-agent/` entirely (src, Cargo.toml, AGENTS.md); remove its entry from the root `Cargo.toml` workspace dependencies. +- [crates/promptforge/src/lib.rs](crates/promptforge/src/lib.rs): delete the `agent` module; remove the dependency from [crates/promptforge/Cargo.toml](crates/promptforge/Cargo.toml). +- `LuaProgram` stays: it is the compiled-chunk type behind `Block::Lua`, not the standalone runner. +- Tests in the same commit: workspace builds; grep confirms no `promptforge_agent` references outside `vibe/`. + + + + + +### Step 4: Rewrite agent guides for .md directory agents [completed] + +- Component: lua-path-removal +- Piece: guide-rewrite +- Rewrite [guide/promptforge-agent-guide.md](guide/promptforge-agent-guide.md), [guide/src/agent/01-agent-programs.md](guide/src/agent/01-agent-programs.md), and [crates/workshop-sessions/README.md](crates/workshop-sessions/README.md): directory agents are `.md` prompts discovered under `agents.path`; `chat.md` shadows the embedded built-in; no `.lua` mention outside historical `vibe/` docs. +- Tests in the same commit: guide build and any doc-link checks pass. + + + + + +### Step 5: Rename promptforge-core-support to shared-promptforge-api [completed] + +- Component: shared-vocabulary +- Piece: crate-rename +- `git mv crates/promptforge-core-support crates/shared-promptforge-api`; rename the package; update every dependent manifest (`promptforge-parser`, `promptforge-lua`, `promptforge-model-client`, `promptforge-core`, `workshop-protocol`, `workshop-gateway`, `workshop-sessions`) and the root `Cargo.toml` workspace entry. +- Constraint: `shared-promptforge-api` keeps zero product-crate dependencies. +- Tests in the same commit: full workspace build and the renamed crate's existing unit tests pass under the new name. + + + + + +### Step 6: Sink model vocabulary into shared-promptforge-api [completed] + +- Component: shared-vocabulary +- Piece: model-sink (sequential after crate-rename: the destination crate must exist under its new name) +- Move from `promptforge-model-client` into a `models` module: `ModelId`, `ModelIdError`, `ModelCatalog`, `ModelCatalogError`, `ModelDescriptor`, `ThinkingMode`. Move `StreamDelta` into a `wire` module. +- `promptforge-model-client` keeps transport (`GatewayClient`, `GatewayEndpoint`, `SecretString`, `SecretError`, `fetch_model_catalog`, `subscribe_progress`), binding machinery (`ModelBinding`/`ModelSet`/`ModelView`/`ModelBindOpts`/`ModelInvocation`, `CompletionError`/`CompletionErrorKind`), value types (`Temperature`, `TemperatureError`, `CompletionOptions`), and all wire types (`Message`, `ToolSchema`, `ToolSchemaError`, `ToolCall`, `ToolArguments`, `Completion`, `CompletionResult`); it imports the sunk symbols from `shared-promptforge-api`. +- Update doc examples: `use promptforge_model_client::model::...` becomes `use shared_promptforge_api::models::...`. +- Tests in the same commit: model-client unit tests pass against the shared types; doc examples compile. + + + + + +### Step 7: Dissolve promptforge-tools into shared-promptforge-api [completed] + +- Component: shared-vocabulary +- Piece: tools-sink (sequential after crate-rename) +- Move the entire crate (`ids.rs`, `output.rs`, `registry.rs`) into a `tools` module: `Tool`, `ToolCatalog`, `ToolCatalogError`, `ToolCatalogErrorKind`, `ToolId`, `ToolIdError`, `ToolIdErrorKind`, `ToolOutput`, `OutputTrust`, `ToolError`, `ToolErrorKind`. +- Delete `crates/promptforge-tools/` and its root `Cargo.toml` entry; switch dependents `promptforge-webfetch`, `promptforge-web-search`, `promptforge-lua`, `promptforge-core` to `shared-promptforge-api`. +- Add `async-trait` and `serde_json` to `shared-promptforge-api`'s Cargo.toml. Drop the deprecated free function `untrusted::wrap`. +- `promptforge-tool-picker`'s private `ToolId` ([crates/promptforge-tool-picker/src/catalog.rs](crates/promptforge-tool-picker/src/catalog.rs) line 38) stays. +- Update doc examples: `use promptforge_tools::...` becomes `use shared_promptforge_api::tools::...`. +- Tests in the same commit: the moved tool unit tests pass in their new home; dependent crate tests pass; doc examples compile. + + + + + +### Step 8: Rename promptforge-core to promptforge-api and delete the facade [completed] + +- Component: api-crate +- Piece: crate-rename +- `git mv crates/promptforge-core crates/promptforge-api`; rename the package; move integrator-facing metadata from the retiring facade. +- Delete `crates/promptforge/` (facade, AGENTS.md, README). Root `Cargo.toml`: replace the `promptforge-core` and `promptforge` workspace entries with `promptforge-api`. +- Mechanical: update `promptforge_core::` self-references in tests, benches, and doc comments. +- Tests in the same commit: the executor test suite passes under the new crate name. + + + + + +### Step 9: Simplify the run API and re-export posture [completed] + +- Component: api-crate +- Piece: api-signatures (sequential after crate-rename: edits land on the renamed crate) +- `ResolutionContext` ([crates/promptforge-api/src/execute/gateway.rs](crates/promptforge-api/src/execute/gateway.rs)): make the tool picker optional so capability-free agents pass `None`. +- `RunConfig` ([crates/promptforge-api/src/execute/config.rs](crates/promptforge-api/src/execute/config.rs)): absorb the store handle as an optional entry defaulting to `promptforge_vfs::empty()`; `run()` drops its `&vfs` parameter, becoming `run(&Prompt, &str, ResolutionContext, RunConfig)`. +- Re-exports: keep only `parser` (`Prompt`, `ParseError`, `Block`, `Section`, ...) and `client` (`GatewayClient`, `GatewayEndpoint`, `SecretString`, `CompletionError`/`CompletionErrorKind`); drop the `tools`, `observe`, `model`, `store` re-export modules and the root `CancelHandle` re-export. +- Tests in the same commit: executor API tests migrated to the new `run()` signature and optional picker. + + + + + +### Step 10: Reimplement gateway progress subscription [completed] + +- Component: consumer-migration +- Piece: progress-reimplementation (isolated: highest-risk item, reimplements a gateway SSE subscription over a different HTTP client; pulled ahead of the edge collapse because workshop-gateway cannot drop its `promptforge-model-client` edge until this lands) +- [crates/workshop-gateway/src/gateway_progress.rs](crates/workshop-gateway/src/gateway_progress.rs): reimplement the `GET /admin/progress` subscription over the workshop's own gateway client instead of `promptforge_model_client::model::subscribe_progress`. Observable behavior must be identical. (The plan's original `workshop-sessions` path was stale; the file lives in `workshop-gateway`.) +- Note: the workshop-protocol migration this component also owns already landed in Steps 5-7 (manifest renamed to `shared-promptforge-api`, imports rewritten); confirm it here. +- Tests in the same commit: gateway progress integration tests produce the same event stream as before; workshop-protocol unit tests pass. + + + + + +### Step 11: Migrate workshop-gateway and workshop-protocol edges [completed] + +- Component: consumer-migration +- Piece: small-consumers (sequential after progress-reimplementation: dropping the model-client edge requires the new progress path) +- Move agent `GatewayClient` construction out of [crates/workshop-gateway/src/gateway_binding.rs](crates/workshop-gateway/src/gateway_binding.rs) into `workshop-sessions` ([crates/workshop-sessions/src/agents.rs](crates/workshop-sessions/src/agents.rs) and [crates/workshop-sessions/src/agents/supervisor/effects.rs](crates/workshop-sessions/src/agents/supervisor/effects.rs)), building it from the snapshot's base URL and API key via the `promptforge-api::client` re-exports (`GatewayClient`, `GatewayEndpoint`, `SecretString`). +- [crates/workshop-gateway/Cargo.toml](crates/workshop-gateway/Cargo.toml): drop the `promptforge-model-client` edge, leaving exactly one edge, `shared-promptforge-api`; rewrite imports. +- [crates/workshop-protocol/Cargo.toml](crates/workshop-protocol/Cargo.toml): confirm the single `shared-promptforge-api` edge (landed in Step 5). +- Tests in the same commit: workshop-gateway, workshop-protocol, and workshop-sessions unit tests pass. + + + + + +### Step 12: Migrate workshop-sessions and workshop-server edges [completed] + +- Component: consumer-migration +- Piece: sessions-migration (sequential after the gateway edge collapse: the crate's imports change once, after its client construction is settled) +- [crates/workshop-sessions/Cargo.toml](crates/workshop-sessions/Cargo.toml): collapse the remaining promptforge deps to two edges, `promptforge-api` and `shared-promptforge-api`; rewrite all imports. +- Delete empty-`ToolPicker`, empty-`ToolCatalog`, and `vfs: VfsRef` field construction in [crates/workshop-sessions/src/agents/supervisor/effects.rs](crates/workshop-sessions/src/agents/supervisor/effects.rs) and [crates/workshop-sessions/src/agents.rs](crates/workshop-sessions/src/agents.rs), using the Step 9 defaults. +- [crates/workshop-server/Cargo.toml](crates/workshop-server/Cargo.toml): dev-dependencies reference only `promptforge-api` and `shared-promptforge-api`. +- Tests in the same commit: the workshop-sessions suite and workshop-server integration tests pass. + + + + + +### Step 13: Delete product-integration-tests [completed] + +- Component: consumer-migration +- Piece: dead-crate-deletion +- Delete `crates/product-integration-tests/` entirely and remove it from the root `Cargo.toml` workspace dependencies. +- Tests in the same commit: `cargo metadata --locked` no longer lists the crate; the workspace builds. + + + + + +### Step 14: Rename xtask to build-xtask [completed] + +- Component: boundary-enforcement +- Piece: xtask-rename +- Rename `crates/xtask/` to `crates/build-xtask/`: rename the directory, update the package name in its Cargo.toml, update the root `Cargo.toml` workspace dependency entry, update the `xtask` alias in `.cargo/config.toml`, and update any CI or doc references. +- Tests in the same commit: `cargo test -p build-xtask` and the `cargo xtask tidy` alias both work. + + + + + +### Step 15: Add the product-boundary check [completed] + +- Component: boundary-enforcement +- Piece: boundary-check (sequential after xtask-rename: the check lands on the renamed crate) +- [crates/build-xtask/src/tidy.rs](crates/build-xtask/src/tidy.rs): add `product_boundary_violations` wired into `all_violations` with a `#[test]` wrapper. The file is currently 332 lines; if the addition would exceed the 500-line ceiling, split a `product.rs` module first. +- Rules encoded across all dependency kinds (normal, dev, build, target-specific) on every workspace manifest: no outside crate may depend on `promptforge-*` except `promptforge-api`; codify the full AGENTS.md product-boundary matrix (promptforge/gateway/workshop isolation). +- Update [crates/promptforge-api/AGENTS.md](crates/promptforge-api/AGENTS.md) and root [AGENTS.md](AGENTS.md) to name the one-door rule. +- Tests in the same commit: the new boundary test passes on the migrated workspace and fails on an injected violation. + + + + + +### Step 16: Run full verification and metadata sweep [completed] + +- Component: verification +- Piece: final-sweep +- `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server`. +- `cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings`, plus `cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings`; `cargo fmt --all --check`. +- `cargo metadata --locked` confirms no outside-product edge to any `promptforge-*` crate except `promptforge-api`. +- Grep sweep: no `promptforge_agent`, `promptforge_core`, `promptforge-core` (non-historical), `product-integration-tests`, or `.lua agent` references outside `vibe/` docs. Historical `vibe/` plans are deliberately untouched. +- Confirm no `promptforge-tools`, `promptforge-agent`, or `product-integration-tests` crate directory exists; all doc examples compile. +- Tests in this commit: the full suite is the verification; only sweep-fixups are committed. + + +- Deferred and out of scope: + - **ThinkingMode ownership.** Two identical enums exist: `gateway_config::ThinkingMode` and the copy in `shared-promptforge-api::models`. Gateway does not depend on the shared copy and must not. If gateway ever wants the shared type, the correct home is a new `shared-gateway-api` crate. Until then, both sides keep their own copy. Doc comment on the shared copy notes: "Deserialized from the gateway's `/v1/models` catalog. If gateway adopts the shared type, move to `shared-gateway-api`." + - **Gateway-normalized inference metrics.** `LlamaTimings` and `VllmMetrics` are backend-specific types that leak through the gateway into the executor, violating the gateway's purpose of normalizing the provider interface. The correct design: the gateway parses backend-specific timing extensions itself (`"timings"` from llama.cpp, `"metrics"` from vLLM), normalizes them into one `InferenceMetrics` struct (TTFT, e2e, mean ITL, tokens/sec, plus `Usage`), and emits that in a standard response field. `LlamaTimings` and `VllmMetrics` move to gateway-internal crates; the normalized struct lives in `shared-gateway-api`. This is a gateway behavior change, not a crate-boundary change, so it is deferred. For now, `LlamaTimings`, `VllmMetrics`, and `ClientTiming` stay in `shared-promptforge-api::events` as fields of `CallMetrics` because they're working, workshop reads them, and the JSONL event log schema includes them. When the gateway normalization lands, `shared-gateway-api` carries the normalized metrics struct and `CallMetrics` collapses to `Usage` plus `InferenceMetrics`. + - **Host-installed tool groups** and a global tool namespace extend `shared-promptforge-api::tools` when they land. + - **Gateway wire-type convergence** is not forced by this plan; `shared-promptforge-api` creates the option without requiring it. + - **ResolutionContext elimination.** After this plan makes the picker optional and moves `ModelCatalog`/`ToolCatalog` to the shared crate, the struct adds nothing over putting its fields into `RunConfig`. A future simplification folds it into `RunConfig`, giving `run(prompt, args, config)` as the minimal call. Not in this plan because it's a separate signature change with its own test migration. + - **Bashkit shell integration.** Bashkit will be a `Tool` whose constructor takes a `VfsRef` clone and an optional command overlay registry (for customizing `ls` over virtual mounts). The `Tool` trait is the right abstraction - the configurability lives in `BashkitTool`'s builder, outside the promptforge API boundary. Impact on `run()` or `RunConfig`: none. + +