From 49d7e9d45a4923e2b1de8157489de8063e15dba9 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 18:43:20 -0700 Subject: [PATCH 01/30] Add GlobalName naming grammar to shared API Introduces the one global naming grammar: a validated name whose arity encodes kind, two segments naming a capability and three naming a tool. Construction goes through a parser that enforces segment count and a lowercase ASCII charset, rejecting empty segments, uppercase, and version syntax, so the arity and charset invariants hold by construction. Errors carry a stable, matchable classification so callers can branch on the failure without matching a private representation. The display form round trips: rendering a parsed name and parsing it again yields an equal value. - `GlobalName` holds its segment list privately and is constructed only through `parse`, so the arity and charset invariants hold by construction; comparison is case-sensitive. - `GlobalNameError` is non-exhaustive with private fields and exposes only `kind()`, so the representation can change without breaking matchers. - `GlobalName::parse` accepts exactly two or three segments and rejects the wrong count, empty segments, control characters, uppercase, non-ASCII, and `@`, mapping each failure to a `GlobalNameErrorKind`. - `Display` joins the segments with `/`, and tests re-parse the rendered form back to an equal value for both arities. - `GlobalName::parse` performs no normalization-collision rejection: names differing only by case or punctuation are distinct values, and that check is deferred to registry scale. Design: new encapsulated-invariant @ crates/shared-promptforge-api/src/names.rs::GlobalName boundary: pub Design: new pure-function @ crates/shared-promptforge-api/src/names.rs::validate_segment deps: str Deferred: normalization-collision rejection, deferred to registry scale Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/shared-promptforge-api/src/lib.rs | 1 + crates/shared-promptforge-api/src/names.rs | 145 +++ .../shared-promptforge-api/src/names/tests.rs | 110 ++ ...2026-09-13-1-capabilities-global-naming.md | 1049 +++++++++++++++++ vibe/ACTIVE | 1 + 5 files changed, 1306 insertions(+) create mode 100644 crates/shared-promptforge-api/src/names.rs create mode 100644 crates/shared-promptforge-api/src/names/tests.rs create mode 100644 vibe/2026-09-13-1-capabilities-global-naming.md create mode 100644 vibe/ACTIVE diff --git a/crates/shared-promptforge-api/src/lib.rs b/crates/shared-promptforge-api/src/lib.rs index 9c71f088..f5d83d04 100644 --- a/crates/shared-promptforge-api/src/lib.rs +++ b/crates/shared-promptforge-api/src/lib.rs @@ -18,6 +18,7 @@ pub mod cancel; pub mod events; pub mod models; +pub mod names; pub mod observe; pub mod tools; pub mod untrusted; diff --git a/crates/shared-promptforge-api/src/names.rs b/crates/shared-promptforge-api/src/names.rs new file mode 100644 index 00000000..c04559ad --- /dev/null +++ b/crates/shared-promptforge-api/src/names.rs @@ -0,0 +1,145 @@ +//! The one global naming grammar. +//! +//! Kind is encoded by arity: capabilities are `namespace/pack` (2 segments) +//! and tools are `namespace/pack/name` (3 segments), so a reader can tell the +//! kind of any name by counting segments. A namespace is reverse-DNS +//! (`org.rustalliance`) or the reserved first-party prefix `promptforge`. +//! Segments are lowercase ASCII alphanumeric plus `-`, `_`, `.`, and +//! comparison is case-sensitive. v1 is unversioned: a `@` is a parse error. + +use std::fmt; + +#[cfg(test)] +mod tests; + +/// A validated global name of two or three segments. +/// +/// Two segments name a capability (`namespace/pack`); three segments name a +/// tool (`namespace/pack/name`). Construct only through +/// [`GlobalName::parse`]; the segment list is private so the arity and +/// charset invariants hold by construction. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct GlobalName { + /// The `/`-separated segments: exactly 2 (capability) or 3 (tool). + segments: Vec, +} + +impl GlobalName { + /// Parses a global name, enforcing the arity and charset rules. + /// + /// # Errors + /// + /// Returns [`GlobalNameError`] when the segment count is not 2 or 3 + /// ([`GlobalNameErrorKind::SegmentCount`]), a segment is empty + /// ([`GlobalNameErrorKind::Empty`]), or a segment contains a character + /// outside the allowed set ([`GlobalNameErrorKind::Control`]). + /// + /// # Examples + /// + /// ``` + /// use shared_promptforge_api::names::GlobalName; + /// + /// let name = GlobalName::parse("promptforge/web/fetch")?; + /// assert_eq!(name.namespace(), "promptforge"); + /// assert_eq!(name.pack(), "web"); + /// assert_eq!(name.to_string(), "promptforge/web/fetch"); + /// # Ok::<(), shared_promptforge_api::names::GlobalNameError>(()) + /// ``` + pub fn parse(s: &str) -> Result { + let segments: Vec<&str> = s.split('/').collect(); + if !(2..=3).contains(&segments.len()) { + return Err(GlobalNameError { + kind: GlobalNameErrorKind::SegmentCount, + reason: "must have exactly 2 segments (namespace/pack) or 3 (namespace/pack/name)", + }); + } + for segment in &segments { + validate_segment(segment)?; + } + Ok(GlobalName { + segments: segments.iter().map(|s| (*s).to_owned()).collect(), + }) + } + + /// Returns the namespace segment (reverse-DNS or `promptforge`). + #[must_use] + pub fn namespace(&self) -> &str { + &self.segments[0] + } + + /// Returns the pack segment. + #[must_use] + pub fn pack(&self) -> &str { + &self.segments[1] + } +} + +impl fmt::Display for GlobalName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.segments.join("/")) + } +} + +/// Validates one segment against the charset rule. +/// +/// A segment must be non-empty and contain only lowercase ASCII +/// alphanumeric characters plus `-`, `_`, `.`. Anything else - including +/// uppercase (comparison is case-sensitive), `@` (v1 is unversioned), +/// control characters, and non-ASCII - is rejected. +fn validate_segment(segment: &str) -> Result<(), GlobalNameError> { + if segment.is_empty() { + return Err(GlobalNameError { + kind: GlobalNameErrorKind::Empty, + reason: "segments must not be empty", + }); + } + for byte in segment.bytes() { + if byte < 0x20 || byte == 0x7f { + return Err(GlobalNameError { + kind: GlobalNameErrorKind::Control, + reason: "segments must not contain a control character", + }); + } + if !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.') { + return Err(GlobalNameError { + kind: GlobalNameErrorKind::Control, + reason: "segments may contain only lowercase ASCII letters, digits, '-', '_', '.'", + }); + } + } + Ok(()) +} + +/// A stable, matchable classification of a [`GlobalNameError`]. +/// +/// Every public error exposes a `kind()` classifier so callers can branch on +/// the failure without matching a private representation (DESIGN-5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GlobalNameErrorKind { + /// The name did not have exactly 2 or 3 segments. + SegmentCount, + /// A segment was empty. + Empty, + /// A segment contained a character outside the allowed set. + Control, +} + +/// The reason a [`GlobalName`] could not be parsed. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("invalid global name: {reason}")] +#[non_exhaustive] +pub struct GlobalNameError { + /// A stable classification of why the name was rejected. + kind: GlobalNameErrorKind, + /// A human-readable reason. + reason: &'static str, +} + +impl GlobalNameError { + /// Returns the stable classification of this error (DESIGN-5). + #[must_use] + pub fn kind(&self) -> GlobalNameErrorKind { + self.kind + } +} diff --git a/crates/shared-promptforge-api/src/names/tests.rs b/crates/shared-promptforge-api/src/names/tests.rs new file mode 100644 index 00000000..1e42be48 --- /dev/null +++ b/crates/shared-promptforge-api/src/names/tests.rs @@ -0,0 +1,110 @@ +use super::{GlobalName, GlobalNameErrorKind}; + +fn kind_of(input: &str) -> GlobalNameErrorKind { + GlobalName::parse(input) + .expect_err("the input must be rejected") + .kind() +} + +#[test] +fn a_two_segment_name_parses_as_a_capability_name() { + let name = GlobalName::parse("promptforge/web").expect("a valid capability name"); + assert_eq!(name.namespace(), "promptforge"); + assert_eq!(name.pack(), "web"); +} + +#[test] +fn a_three_segment_name_parses_as_a_tool_name() { + let name = GlobalName::parse("promptforge/web/fetch").expect("a valid tool name"); + assert_eq!(name.namespace(), "promptforge"); + assert_eq!(name.pack(), "web"); +} + +#[test] +fn a_reverse_dns_namespace_parses() { + let name = GlobalName::parse("org.rustalliance/core").expect("a valid capability name"); + assert_eq!(name.namespace(), "org.rustalliance"); + assert_eq!(name.pack(), "core"); +} + +#[test] +fn display_round_trips_a_two_segment_name() { + let name = GlobalName::parse("promptforge/web").expect("a valid capability name"); + assert_eq!(name.to_string(), "promptforge/web"); + assert_eq!( + GlobalName::parse(&name.to_string()).expect("the display form re-parses"), + name + ); +} + +#[test] +fn display_round_trips_a_three_segment_name() { + let name = GlobalName::parse("promptforge/web/fetch").expect("a valid tool name"); + assert_eq!(name.to_string(), "promptforge/web/fetch"); + assert_eq!( + GlobalName::parse(&name.to_string()).expect("the display form re-parses"), + name + ); +} + +#[test] +fn dashes_underscores_and_dots_are_legal_segment_characters() { + GlobalName::parse("org.rustalliance/my-pack/v1_2.tool").expect("the charset allows - _ ."); +} + +#[test] +fn a_single_segment_is_rejected_as_a_segment_count_error() { + assert_eq!(kind_of("promptforge"), GlobalNameErrorKind::SegmentCount); +} + +#[test] +fn four_segments_are_rejected_as_a_segment_count_error() { + assert_eq!( + kind_of("promptforge/web/fetch/extra"), + GlobalNameErrorKind::SegmentCount + ); +} + +#[test] +fn an_empty_string_is_rejected_as_a_segment_count_error() { + assert_eq!(kind_of(""), GlobalNameErrorKind::SegmentCount); +} + +#[test] +fn an_empty_middle_segment_is_rejected_as_an_empty_error() { + assert_eq!(kind_of("promptforge//web"), GlobalNameErrorKind::Empty); +} + +#[test] +fn a_leading_separator_is_rejected_as_an_empty_error() { + assert_eq!(kind_of("/web"), GlobalNameErrorKind::Empty); +} + +#[test] +fn a_trailing_separator_is_rejected_as_an_empty_error() { + assert_eq!(kind_of("promptforge/"), GlobalNameErrorKind::Empty); +} + +#[test] +fn uppercase_is_rejected_because_comparison_is_case_sensitive() { + assert_eq!(kind_of("Promptforge/web"), GlobalNameErrorKind::Control); + assert_eq!(kind_of("promptforge/Web"), GlobalNameErrorKind::Control); +} + +#[test] +fn an_at_sign_is_rejected_because_v1_is_unversioned() { + assert_eq!(kind_of("promptforge/web@2"), GlobalNameErrorKind::Control); +} + +#[test] +fn a_control_character_is_rejected_as_a_control_error() { + assert_eq!(kind_of("promptforge/we\tb"), GlobalNameErrorKind::Control); +} + +#[test] +fn non_ascii_is_rejected_as_a_control_error() { + assert_eq!( + kind_of("promptforge/w\u{e9}b"), + GlobalNameErrorKind::Control + ); +} diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md new file mode 100644 index 00000000..b96a23f2 --- /dev/null +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -0,0 +1,1049 @@ +--- +name: Capabilities and Global Naming +overview: "Capabilities for PromptForge: globally named capabilities (arity encodes kind: capabilities namespace/pack, tools namespace/pack/name), frontmatter capability activation and tool slots (exact and fuzzy; the open host-offered posture is deferred), declared model roles filled by a trivial satisfaction function into ModelBindings, the Environment/RunContext split by rebuild-ability, prepare/Requirements preflight, and an infallible RunResult interface." +todos: + - id: global-names + content: steps 1-3 - GlobalName grammar, ToolId re-base + built-in id migration, picker ToolId migration + status: pending + - id: interface + content: step 4 - Environment/RunContext consolidation (pure refactor, the parity gate) + status: pending + - id: frontmatter + content: steps 5-6 - capabilities/tools/args/models keys, structured parse error locations + status: pending + - id: capabilities + content: steps 7-9 - Capability trait + RunServices, CapabilityRegistry, prepare/Requirements/per-run VFS + status: pending + - id: binding + content: steps 10-15 - ModelBindings + trivial fill, catalog assembly, ToolBindings + slot filling, Lua surface consolidation, args/argv, prose migration + status: pending + - id: first-party + content: steps 16-17 - the promptforge/web capability, Workshop wiring + status: pending + - id: docs + content: step 18 - guide chapters + AGENTS.md Principles rule + status: pending +isProject: false +--- + +# Capabilities and Global Naming + +**How to Execute This Plan (read first if you are a fresh context):** + +- **Where the code is**: the repository is `promptforge/` in the workspace root (`c:\Users\Vinnie\cursor\promptforge`), a Rust workspace with crates under `crates/`. All `crates/...` paths in this plan are relative to that repo. Related paths, also relative to the workspace root: `promptforge-design/research/` (the evidence documents), `vibe/` (prior plans), `wg21-paperflow/crates/papergate` (the first out-of-repo consumer), and the prompt guide at `promptforge/guide/src/`. +- **Read order**: Product Requirements (what and why) -> Technical Design including the live declarations (the shapes to build) -> Execution Instructions (the components and their ordered steps) -> Testing Plan. Read the Project Survey first if you are new to the codebase - it defines every term (H1/H2, the picker, slots/bindings, the VFS, fanout) and lists the verified current-state facts with file and line references. Consult the Decision Record only when a choice seems arbitrary; every decision records its user quote. +- **Status**: surveyed and decomposed, execution not started. The Project Survey carries `- Status: complete` (build/test/lint/fmt/docs commands discovered 2026-09-13). The Execution Instructions are 18 committable steps in 7 dependency-ordered components, each wrapped in a `` tag pair; the frontmatter todos mirror the components with their step ranges - mark each `complete` as its steps land, and the run appends ` [completed]` to each `### Step N:` heading. The plan passes the vibe-coder contract check (seven H2 sections in order, six balanced contract tag pairs, balanced step tags) and is ready to seed into `vibe/` when a run starts. Reviewed for internal consistency 2026-09-13 (deferrals of the open toolset, the `prompt` reflection global, and the prompt-pack capability are fully propagated). +- **Working agreements** (from the plan's constraints): behavior changes ship with their tests in the same change; the interface consolidation (step 4) is the parity gate (no behavior change, full suite green); do not build anything listed under Deferred - the live shapes leave room for it, that is all. + + + +## Product Requirements + +PromptForge is a prompt-programming system: a prompt is a Markdown file whose YAML frontmatter declares its contract and whose sections contain Lua code that drives models explicitly; the Rust executor runs it, and Workshop (the desktop app) is one host among several the design anticipates (CLI, automation, cloud). Today a prompt that needs tools must describe them in English prose, which a semantic picker (a local embedding model) fuzzy-matches against the installed catalog - so a prompt that needs exactly the shell can fail to bind it, or bind the wrong thing. Meanwhile a survey of Everruns (a production agent platform) showed its harness features - tools, shells, AGENTS.md injection - live in a composable capability layer below the UI, never in it; promptforge has no such layer, and Workshop currently runs every prompt with an empty tool catalog. This plan builds that layer: capabilities (code units that run at setup and make services available), named globally, declared and bound in frontmatter, with model selection inverted from prompt-seeks-model to prompt-declares-roles and host-satisfies. It also rebuilds the executor's interface: a shareable deployment Environment, a per-run RunContext, a prepare step with a requirements report, and an infallible RunResult. The design accumulated across two days of conversation (2026-09-12/13) and draws on a four-way naming survey and the Everruns feature survey. + +- Background: three evidence documents inform this plan and are worth reading first if any decision seems arbitrary: the naming survey (why reverse-DNS namespaces, why the model only ever sees local aliases and never global tool paths, why prefix schemes fail), the Everruns feature survey (what a production agentic harness consists of), and the Everruns integration-path analysis (which of those features need executor integration versus composing as tools and prompts - most compose). +- Problem and users: prompts cannot say what they need (tools, models, services) except through fuzzy prose binding; hosts cannot preflight a prompt; harness features have no activation unit. Users are prompt authors, hosts (Workshop today; CLI and automation later), and capability authors (first-party now, DLL packs later). +- Goals: + - Three distinct units: a pack/plugin is the delivery container (crate, later DLL); a capability is the activation unit (code that runs at run setup and makes services available; v1 contributes tools - mounts, prompt fragments like AGENTS.md injection, and Lua surface are designed and deferred, see Deferred); a tool is an implementation detail a capability surfaces, learned from the capability's documentation. + - Frontmatter installs and binds; H2 sections scope. The frontmatter does two things: it names required capabilities (with optional per-capability config), and it declares tool slots - each slot maps an alias to either an exact global tool path or a fuzzy `want` description (the open host-offered posture is deferred - see Deferred). There is no `tools.bind` in Lua: prepare fills every slot before the run and journals the result. Inside the Lua, `tools.add` scopes aliases per section as today, and `tools.always` is H1-only and prompt-wide. H1's only remaining privilege is `argv` repair: `argv` is writable in H1 and frozen when H1 completes. `tools.add`/`add_local` also work in H1, as ordinary section behavior rather than as a privilege. + - Global names encode kind by arity: capabilities are `namespace/pack`, tools are `namespace/pack/name` (reverse-DNS namespace, MCP-registry convention); a capability's tools live under its full id; the semantic picker leaves the binding path except as the journaled fill function behind fuzzy slots. + - Models are declared requirements, host-satisfied: frontmatter declares roles under prompt-local labels with keywords, descriptions, and a context minimum, and parse exposes them as slots. A fill function maps each slot to a concrete model at prepare; v1's fill is deliberately trivial - every slot gets the host's current model - and the result is journaled as `ModelBindings` (roles label->id, models id->descriptor). The model rides per-run (in Workshop, the dropdown's current selection); the Environment is model-free and never rebuilt. Hard keywords and the context minimum are checked per slot against the filled descriptor and reported, not shopped for. Soft keywords are documentation of author intent. Multi-model satisfaction is deferred as a smarter fill function, not a structural change (see Deferred); the prompt-side schema already supports it. + - The caller's burden collapses to exceptions: `prepare` auto-satisfies and returns a `Requirements` report listing only what needs human attention. + - The interface never fails to produce a result: `run` returns `RunResult`, with domain outcomes as values. +- Non-goals: `calls:` frontmatter and recursive preflight (the user has other ideas for `call`); the DLL addon ABI itself (the `addon_dll_abi` plan owns it); capability interfaces / dependency injection; multi-major coexistence; `multimodal`/`visual` model keywords; the durable platform tier (event-sourced replay, workers, control plane). +- Success criteria: a prompt declares capabilities, model roles, and tool slots in frontmatter and runs end to end in Workshop: capability activated, slots filled, tool called from a section; a missing required capability and an unmet model requirement are reported by `prepare` before any model call; two concurrent runs share host-file conflict detection but never conflict on their stores; the full existing test suite stays green through the interface consolidation. +- Constraints: + - Runtime vs capability dividing line: the criterion is not "is it language surface" but "does it reach outside the run." The store is always present (pure interiority: the run's own scratchpad, defined by the run's determinism contracts; same for `var`, `log`, the models namespace, cancellation). A Lua `fs` table reaches through the VFS to host files, so it is a capability. Never package an interior primitive as a capability; never assume an exterior one. + - Behavior changes ship with tests in the same change; existing alias/scope behavior tests are preserved. + - No new structural enforcement: frontmatter keys extend the existing parser; no new parsers or allowlists. The capability co-activation conflict check is behavior validation at prepare, not a source parser. + - Dependency rules: `shared-promptforge-api` stays free of product crates (it gains a dependency on `shared-vfs`, std-only, shared-* tier); `CapabilityRegistry` lives in `promptforge-api`; workshop consumes through the one interface only. + - `RunServices` leaves room for the DLL addon plan: DLL packs register capabilities into the same registry via host-side adapters later. + - User-facing strings are model-facing strings: error and status messages are written assuming model consumption - concise, factual, self-contained - because any string the system produces may be read by a model (tool errors, failure notices, sub-run output). Recorded as a root `AGENTS.md` Principles rule by this plan. +- Open questions: + - Whether the section execution graph can be guaranteed directed and acyclic (user exploring, 2026-09-13 - NOT decided: "I am thinking it would make PromptForge very powerful if we could guarantee that the execution graph was directed and acyclic with respect to sections. I dont know if it is possible. And I'm not sure it would be too limiting"). + - The leading candidate rule (user, 2026-09-13): `jump` may target only a CHILD or a FORWARD SIBLING of the current section. Every permitted edge strictly advances document position, so document order is itself a topological sort, and acyclicity falls out of a local per-jump positional check at parse time - no graph algorithm needed. In the user's words: "control never starts anything above where you are on the page." + - What the rule preserves: today's two legitimate control-flow modes - transferring to a sibling section, and descending into a child section with the parent resuming afterward (a resumption, not a fresh execution). + - What the rule kills: backward jumps, jumps to uncles or ancestors, and mutual cycles. + - What a DAG buys: structural termination of the walk; a journal that is a path through a DAG (analyzable, diffable, replayable); topological readability. + - What it forbids, and where those needs go instead: section-level retry loops (covered by the `clear_context()` idea - reset the section's model context to its entry baseline while the store and `var`s persist, journaled so replay truncates identically) and section-graph state machines (assigned to `models.loop` within a section, or to the out-of-scope durable tier). + - Sub-questions to resolve before adopting: (1) `call` must carry the same restriction or the guarantee leaks through mutual calls; (2) off-walk sections (today reachable only by jump) must sit as child or forward sibling of every jumper - where they live today is an evidence question; (3) double execution without cycles (call a forward sibling, then fall through to it later) - allowed (still acyclic) or forbidden (one fresh execution per section)?; (4) whether jump targets can be computed at run time today or are always literals (the static check needs literals); (5) whether any shipped prompt or test relies on cycles or re-entry; (6) whether fanout arms are sections or inline code. + +## Functional Specification + +A prompt's contract is fully static: frontmatter declares capabilities, typed args, model roles, and tool slots; prepare fills every slot and journals the bindings; H2 sections scope aliases. The host builds one Environment per deployment (never rebuilt), creates a RunContext per run carrying the per-run inputs (the current model, observer, cancel), prepares it, adjusts only what the Requirements report flags (including seeding declared inputs through `ctx.vfs`), and calls run. Every run produces a journaled RunResult: success text, cancellation, or a failure notice whose kind tells the host whose move it is. + +- Actors and workflows: + - Prompt author: declares `capabilities:`, `tools:`, `args:`, and `models:` in frontmatter; creates local tools and repairs `argv` in H1; scopes and advertises in H2; weaves natural-language guidance into instructions; interprets hard gates once at the top of the run via the decision-tool idiom. + - Host (Workshop, CLI, automation): builds the Environment once (registry, client, base VFS - never rebuilt); per run, creates the RunContext carrying the per-run inputs (the current model, observer, cancel), calls `prepare`, reads the `Requirements` report, adjusts the RunContext, calls `run`. The zero-burden path is one call: `env.run(&prompt, args, ctx)`. Hosts seed declared inputs through `ctx.vfs` before the run and read declared outputs through a `VfsRef` cloned beforehand (Arc-backed, cheap), since `run` consumes the context. In Workshop the current model is the dropdown selection, set on each run's context - a selection change simply takes effect on the next run; a CLI takes it from a flag or env var. + - Capability author: implements `Capability`; v1 contributes tools (mounts, prompt fragments, and Lua surface are deferred - see Deferred). +- Inputs and outputs: + - The YAML is the whole contract (user, 2026-09-13): `capabilities:` install, `tools:` bind, `models:` declare, `args:` type - one static declaration a host can read programmatically before any run. This reverses the earlier "YAML never names tools" rule, which existed to keep binding in the Lua; with `tools.bind` gone entirely, the objection went with it. Frontmatter remains about what must exist and what the prompt wants; the Lua remains about what gets done with it. + - Frontmatter schema (all keys optional; `deny_unknown_fields` stays): + +```yaml +capabilities: + - promptforge/web # required, plain string + - ref: io.github.corp/mcp # optional, with config + optional: true + config: { } # prompt-side config only; + # user config comes from the host +tools: + search: promptforge/web/search # exact: alias -> global path + fetch: promptforge/web/fetch + wiki: # fuzzy: the picker fills at + want: "searches private wikis" # prepare; the fill is journaled + optional: true # unfillable -> skip-and-log + # (the reserved `open` key for + # host-offered tools is deferred) +args: + use_mcp: + type: boolean + default: true + description: "Search MCP-connected private sources" +models: + analyst: + keywords: [frontier, thinking] # closed vocabulary; hard keywords and + min_context: 200000 # the minimum are CHECKED against the + description: "deep reasoning" # filled model; soft keywords document + triage: # intent + keywords: [fast, small] + description: "quick triage of search results" +``` + + - Lua surface: + +```lua +-- tools arrive bound: the YAML slots were filled at prepare. +-- The model sees aliases only; advertising is the Lua's job. +tools.always("search") -- H1: prompt-wide +-- in a section: tools.add("fetch") -- H2: scoped + +-- conditional availability is advertise-time, not bind-time: +-- the wiki slot is filled iff the optional MCP capability activated +if argv and argv.use_mcp then + tools.always("wiki") -- arg-gated; probing whether the slot actually + -- filled awaits the deferred `prompt` global +end + +-- models: labels only, satisfied by the host at prepare time +-- (the labels declared in the frontmatter above: analyst, triage) +models.use("triage") -- never a concrete model id or keyword +models.default("analyst") -- fallback role +-- handles are inspectable: the FULL truth about satisfaction +-- (handles already expose name, model_id, description, context, +-- thinking, temperature, max_tokens today - new: label, capabilities) +local m = models.get("analyst") -- frozen handle +assert(m.context >= 200000) -- actual window of the resolved model +-- m.thinking, m.model_id, m.label, m.capabilities (actual full keyword +-- set, possibly exceeding the request) + +-- self-reflection on the prompt's own frozen frontmatter +-- (prompt.name, prompt.models, prompt.args, prompt.capabilities, +-- prompt.tools) is DEFERRED - see Deferred: the `prompt` global +``` + + - The run's args (user, 2026-09-13): two plain globals, no container, no magic. `args` is the exact passed string, ALWAYS - unchanged from today, so legacy prompts and `{{ args }}` are untouched by definition. `argv` is the parsed JSON on success, nil otherwise - so `if argv then` is the idiomatic malformed check, and the repair pattern (H1 inference plus a local capture tool, the decision-tool idiom) works from `args`. Valid JSON scalars make `argv` a number or boolean; JSON `null` reads as nil. The executor never hard-errors on shape: enforcement belongs to the prompt's H1 - strict prompts access declared fields and error on absence, tolerant prompts repair. `argv` is writable in H1 ONLY and frozen when H1 completes (user, 2026-09-13: "being able to look at a prompt, and know that argv can only change in H1, has value") - so the repair pattern lives in H1: read the broken input from `args`, infer the correction, capture it via the local tool, assign `argv = repaired`, and every downstream section sees the repaired value; a section assigning `argv` mid-walk is an error. The frontmatter `args:` declaration advertises, documents, and derives the tool schema; it does not enforce. + - Every prompt has an args declaration (user, 2026-09-13: "there must be no freeform prompts"): a prompt with no `args:` key gets the default - one optional string field named `prose` (description: "Freeform input for this prompt"). Optional means a tool call may omit the field entirely, and absent is not the empty string: `{}` and `{ "prose": "" }` are distinguishable in Lua (nil vs ""). Because every prompt's advertised tool schema is its real declaration, every prompt is tool-exportable by construction and nothing is synthetic (tool export itself arrives with the deferred prompt-pack - see Deferred). The channels converge with ONE spelling (user, 2026-09-13): prose at the interface of a default-declared prompt is wrapped into the default shape - `argv = { prose = "" }` - so the input is `argv.prose` on both channels, and `args` always holds the exact passed string. The wrap is conformance to the declared schema, not a fiction: the default declaration IS the prose contract. Structured declarations never wrap - for them non-JSON input is `argv == nil`, which crisply means "a structured prompt got non-JSON." (Vocabulary note: `prose` is also the Lua global for a section's pending Markdown buffer; the overlap is harmonious - both mean "the text" - and the guide says so in one line.) + - Args in prose substitution (user, 2026-09-13): no new syntax. `{{ args }}` renders the raw string, unmodified. `argv` joins the substitution namespaces: `{{ argv }}` renders the parsed value (compact JSON for tables, the existing whole-value rule) and `{{ argv.query }}` indexes a table `argv` with the existing dotted-path rule; dotted indexing into a scalar stays a catchable substitution error, never a silent empty string. + - The run's result: `RunResult` (see Technical Design). +- States and validation: + - Parse time: capability id shape (2 segments), tool slot shape (alias grammar on keys; an exact value parses as a 3-segment ToolId; a fuzzy slot has a `want` string; the reserved `open` key is deferred, so `deny_unknown_fields` rejects it in v1), arg name/type sanity, model label grammar (same as aliases), keyword membership (unknown keywords are parse errors - typo safety), config is plain JSON-ish data; errors carry line/column (see `SourceLocation`). (v1 is unversioned: a `@` in a capability id is a parse error; version pins are deferred.) + - Prepare time: required capabilities resolve against the registry or land in `Requirements.missing_required` (an exact tool slot's prefix names its capability, so a slot whose capability is inactive lands there too); absent optionals are skipped and logged; capability co-activation conflicts (bashkit vs terminal) fail preparation naming both; the fill function binds every model slot (v1: all to the current model) with hard keywords and the context minimum checked against the filled descriptor into `Requirements.unmet_requirements`; fuzzy tool slots fill via the picker and the fills are journaled. + - Run time: only filled slots are visible to `tools.add`/`always` and `tools.call`; advertising, not binding, is the Lua's gate - an unadvertised alias is invisible to the model. +- Errors and recovery: + - The interface is infallible: `run` returns `RunResult` - `Ok(String)`, `Cancelled`, or `Failure(RunError)`. Domain outcomes (including "the prompt declined") are values, never thrown errors; the variant is for code, the payload for humans and models. + - A failed H1 assertion ends the run before the walk as `RunResult::Failure` with the new `RunErrorKind::RequirementsUnmet` - the failure notice as content ("the prompt failed, and here's why," no different in kind than an inference-produced result), with the kind machine-readable for hosts, supervisors, and evals. Authors choose `assert` (fatal) vs conditional (adaptive) per check. + - The zero-burden `env.run` refuses a prompt whose declared model requirements the one model cannot meet: `RunResult::Failure` with `RequirementsUnmet` and a model-readable notice (user, 2026-09-13: "a suitable string that a model can read" - when the deferred prompt-pack lands, it arrives as tool output when the prompt runs as a sub-run tool). The multi-step path lets a host read the report and proceed deliberately anyway. + - Domain failure vs infrastructure fault is a property of the `RunErrorKind`: `RequirementsUnmet`/`Lua`/`ContextExhausted` are domain (operator's or author's move); `Completion`/`Internal` are infrastructure (platform's move). + - Host cancellation maps to the top-level `RunResult::Cancelled` variant, never to `Failure(Cancelled)`; the `Cancelled` kind remains for mid-run classification. +- Security and privacy behavior: + - User-specific capability configuration (which MCP servers, credentials) is host-supplied via `RunServices`, never named in the prompt; the prompt declares the optional capability, the host activates and configures it from user settings. + - Capability co-activation rules (bashkit vs terminal exclusivity) and high-risk gating attach at the capability level; the subagent spawn is the policy/approval checkpoint for sandbox-escaping work. + - Prompt fragments carry `OutputTrust`; AGENTS.md content arrives `Untrusted` and flows through the existing guard-wrap machinery. +- Acceptance criteria: + - A prompt with `capabilities:`, `tools:`, and `models:` frontmatter runs in Workshop end to end: capability activated, tool slots filled, model slots filled, tool called from a section. + - `prepare` reports a missing required capability and an unmet model requirement, each naming the frontmatter key, and logs skipped optionals. + - A prompt with no `capabilities:`/`tools:`/`models:` keys behaves exactly as today. + - The claims isolation matrix passes: two concurrent runs writing the same store path proceed without conflict; two concurrent runs writing the same host file through the shared base hit a determinism violation. + + + + +## Technical Design + +The architecture has three units (pack delivers, capability activates, tool serves) and the contract is one static YAML declaration (capabilities install, tools bind, models declare, args type) filled at prepare. The executor's interface splits by rebuild-ability: an Environment holding what is never rebuilt (registry, client, base VFS, max_depth) and a RunContext holding what can change per run - including the current model - created by the host, enriched by prepare, owned by the executor during run. Naming encodes kind by arity: capabilities are `namespace/pack`, tools are `namespace/pack/name`. The semantic picker becomes the journaled fill function behind fuzzy tool slots, and later powers the discovery capability when that lands. The per-run VFS is a fresh router mounting the shared base plus a fresh store backend - never an overlay - because claims are shared per storage, not per namespace. + +```mermaid +flowchart LR + FM[frontmatter] --> Parse[parser] + Parse --> Prepare[prepare] + Registry[(CapRegistry)] --> Prepare + Prepare --> Create[cap create] + Services[RunServices] --> Create + Create --> Contrib[contribution] + Contrib --> Catalog[ToolCatalog] + Catalog --> Fill[slot fill] + Prepare --> Req[Requirements] + Fill --> Bindings[Bindings] + Bindings --> Run[run] +``` + +- Architecture: + - Capability activation: `Environment::prepare` resolves frontmatter capabilities against the registry, calls `create(&RunServices)` per present capability in declaration order, and assembles the contributed tools into the run's `ToolCatalog`. (v1 contributions are tools-only; mounts/fragments/Lua surface are deferred - see Deferred. When mounts land, the mechanics will be: a capability sees the base router during `create` and requests mounts; those mounts are then overlaid onto the run's router afterward. An overlay shares the run's claims table, which is correct here because capability storage is run-scoped - same run, same storage. Only the per-run store needed a fresh claims table, because two concurrent runs' stores are different storage.) + - Bridge capabilities (deferred from v1; recorded here because they shape `RunServices`): capabilities that create things (fs, bashkit) are self-contained; capabilities that bridge to host services (user-input, later MCP-with-user-servers) consume a `RunServices` field the host fills. Both declare identically in frontmatter; the difference is invisible to the prompt author. When `user-input` converts: it contributes the model-visible input tool plus the `user_input()` Lua global, both wired to `RunServices.input`; no broker installed degrades to today's unavailable-fallback and is reported as a service gap (a `Requirements` field to add then). + - Model satisfaction (user, 2026-09-13: slots plus a trivial fill): the parsed prompt exposes declared roles as a Vec of slots; prepare's fill function maps each slot to a concrete model, and v1's fill is deliberately stupid - every slot gets the RunContext's current model. The result is journaled as `ModelBindings` (roles label->ModelId, models ModelId->ModelDescriptor; handles resolve label->id->descriptor). The boundary is exactly `run()` and above: from the prompt's perspective the full infrastructure exists - it declares roles as if a catalog will shop for them, and the Lua `models` namespace and inspectable handles speak that abstraction (`prompt.models` reflection is deferred); the trivial fill is the one-model host satisfying the full contract, and the prompt cannot tell the difference. Hard keywords (`thinking`, `no-thinking`) and the context minimum are CHECKED per slot against the filled descriptor - `min_context: 200000` against a 32k model lands in `Requirements.unmet_requirements` naming the role - they do not filter anything, there being nothing to filter in v1. Soft keywords (`frontier`, `fast`, `small`, `creative`, `chat`) are documentation of author intent. Multi-model satisfaction arrives later as a smarter fill function, not a structural change - see Deferred. + - Tool slot filling: exact slots fill by identity against the assembled catalog (an exact path's first two segments name its capability, so a slot whose capability is inactive lands in `missing_required`); fuzzy slots fill via the picker over the catalog, and every fill is journaled so hosts and evals see what the fuzz resolved to. The result is `ToolBindings` (alias->ToolId, ToolId->tool), journaled at run start. Binding and advertising are separate facts. Binding is decided entirely at prepare: everything a binding decision could depend on (the frontmatter, the args, which capabilities activated) is known by then - binding was already H1-only even before this plan, so no information appears later that could change it. What remains for run time is advertising: the Lua decides per section which already-bound aliases the model gets to see, and conditional availability (arg-gated via `argv`) is expressed there. + - Per-run VFS: `prepare` builds a fresh router per run mounting `env.base_vfs` at `/` (shared storage; the base's claims table catches cross-run host-file conflicts under the caller's identity) plus a fresh memory backend at the store mount (per-run storage, per-run claims). Never an overlay: overlay shares the claims table, which is only correct for two views of the SAME storage; concurrent runs' stores are different storage and must not share claims. The Environment's base is built with host roots only - it must not carry the store mount (`promptforge_vfs::empty()` installs one; do not reuse it as the base). Policy composes in two layers: the run's ModePolicy installs on the per-run router, and ops routed to the base also consult the base's policy; both must allow, so the base can tighten but never loosen. + - Verified against the implementation (2026-09-13 review of `shared-vfs`): `VfsRefBuilder::build()` gives a fresh claims table (`handle.rs` ~287-300); `impl Vfs for VfsRef` forwards the caller's identity into the base's claims (`handle.rs` ~674-693); the nested-claims case is covered by `a_mounted_handle_applies_its_own_claims_under_the_callers_identity` (`router.rs` ~641-667) and overlay sharing by `an_overlay_shares_the_bases_claims_table` (~792-808); release chains are clean across runs (`router.rs` ~315-327); the op sink fires once with the caller's origin (`handle.rs` ~260-265, ~599-609). + - DLL constraint: exporting the Lua API through the ABI is rejected - the DLL never touches the VM. The addon ABI has exactly one verb (`call`); everything a DLL offers is declared as data and materialized host-side (tools via adapters, VFS via the HostVfs facade). Lua surface contributions are accepted only from in-process capability crates; DLL packs contribute tools (and, when they land, mounts and fragments) only. The escape hatch (designed, not built): a declarative Lua-surface schema with a generic host bridge routing each call through the addon `call` ABI - a translation of the deferred `LuaNamespace` design, not a redesign. +- Modules and interfaces: see the declarations below. `shared-promptforge-api` gains modules `names` and `capabilities` and a dependency on `shared-vfs`; `promptforge-api` gains `Environment`, `RunContext`, `Requirements`, `CapabilityRegistry`, `ModelBindings`, `ToolBindings`, and the new interface; `promptforge-parser` gains the frontmatter keys; `promptforge-lua` loses `tools.bind` and gains the descriptor surface on model handles (the `prompt` reflection global - which does not exist today - is deferred; see Deferred: the `prompt` global). + +### Live declarations + +In `shared-promptforge-api`: + +```rust +// ---- names (new module) ---- + +/// The one global naming grammar. Kind is encoded by arity: +/// capabilities are namespace/pack (2 segments), tools are +/// namespace/pack/name (3 segments). Namespace is reverse-DNS +/// (org.rustalliance) or the reserved first-party prefix +/// `promptforge`. +pub struct GlobalName { /* private: segments (2 or 3) */ } + +impl GlobalName { + pub fn parse(s: &str) -> Result; + pub fn namespace(&self) -> &str; + pub fn pack(&self) -> &str; +} + +pub struct GlobalNameError { /* kind: SegmentCount | Empty | Control */ } +// (NormalizationCollision deferred - it matters at registry scale, not +// day one; see Deferred.) + +// tools::ToolId becomes a newtype over GlobalName (was 2-part +// server/name, e.g. promptforge/web_fetch), now requiring exactly +// 3 segments: namespace/pack/name. Dropping the last segment of any +// tool id always yields the id of the capability that contributed it +// (promptforge/web/fetch comes from promptforge/web, no exceptions). +// This holds by construction: tools exist only after a capability's +// create() returns them, so prepare checks each contributed tool's +// prefix against that capability's id when assembling the catalog. +// Built-ins migrate: promptforge/web_fetch -> promptforge/web/fetch. +pub struct ToolId(GlobalName); + +impl ToolId { + pub fn name(&self) -> &str; + pub fn capability(&self) -> CapabilityId; // the prefix, as its own id +} + +// ---- capabilities (new module) ---- + +// 2 segments (namespace/pack). +pub struct CapabilityId(GlobalName); + +/// The activation unit. Code that runs at run setup and makes services +/// available. Delivered in packs (crates now, DLLs via adapters later). +/// v1 is unversioned: a name resolves to the only installed capability +/// (versioning is deferred; see Deferred). +pub trait Capability: Send + Sync { + fn id(&self) -> &CapabilityId; + fn description(&self) -> &str; + fn create(&self, services: &RunServices) -> Result; +} + +/// What a capability is given at activation. Non-exhaustive, so new +/// fields can be added later without breaking existing capability +/// implementations. Host-supplied per-capability config (the user's +/// MCP servers, credentials) arrives here, never via the prompt. +#[non_exhaustive] +pub struct RunServices { + pub vfs: VfsRef, // shared-vfs: the run's filesystem + pub cancel: CancelToken, // the existing cancellation token type + // input broker, observer, model client: added when a bridge + // capability needs them (see Deferred: bridge capabilities) +} + +/// What a capability contributes. v1 is tools-only (2026-09-13 scope +/// review): mounts, prompt fragments, and Lua surface are deferred +/// until the fs / agents-md / MCP capabilities that need them land. +/// The struct is Default and grows without redesign. +#[derive(Default)] +pub struct Contribution { + pub tools: Vec>, // ids under the capability's own full id + "/" +} + +// kind plus a message written to be read by a model, mirroring ToolError +pub struct CapabilityError { /* ... */ } +``` + +In `promptforge-api`: + +```rust +// promptforge-api::execute + +/// What exists in this deployment and its standing policy. +/// Safe to share across concurrent run() calls (Sync); built once per +/// host and NEVER rebuilt (user, 2026-09-13): everything that can +/// change per run rides the RunContext. Model-free: the gateway's +/// model list is a host-UI concern (the Workshop dropdown) and never +/// crosses this interface. +#[non_exhaustive] +pub struct Environment { + registry: Option, + // no picker: it is executor-internal machinery, never caller-provided + + // shared services + client: Option, + base_vfs: VfsRef, // host roots; the store mount is added per run + + // composition guard: maximum MODEL-ORCHESTRATED prompt-tool + // nesting (Lua tools.call recursion does not accrue - it is + // operator-written deterministic code). Deliberately low + // (default 3; user suggested 3 or 5). Copied into every RunContext. + // Live from day one (user, 2026-09-13: "keep the depth/max_depth"); + // its only consumer, the prompt-pack sub-run adapter, is deferred. + max_depth: u32, +} + +impl Environment { + pub fn new() -> Environment; + pub fn registry(self, registry: CapabilityRegistry) -> Environment; + pub fn base_vfs(self, vfs: VfsRef) -> Environment; + + /// Enriches the caller-created RunContext against the prompt's + /// declarations: activates the declared capabilities, assembles + /// the catalog, fills every slot - model slots via the fill + /// function (v1: every role to the context's current model), + /// tool slots exact/fuzzy - and checks requirements. + /// The report lists only what needs human attention. + pub fn prepare(&self, prompt: &Prompt, ctx: RunContext) -> (RunContext, Requirements); + + /// The zero-burden path: prepares implicitly and fails on + /// unsatisfiable requirements - missing required capabilities AND + /// unmet model requirements (user, 2026-09-13). The failure notice + /// is a model-readable string: when the deferred prompt-pack + /// lands, it may arrive as tool output when the prompt runs as a + /// sub-run tool. (The convenience lives on + /// Environment: the free `run` receives an already-prepared + /// RunContext and has nothing to prepare from.) + pub fn run(&self, prompt: &Prompt, args: &str, ctx: RunContext) -> RunResult; +} + +/// One run. Created by the host from the Environment carrying the +/// per-run inputs, enriched by prepare, owned by the executor during +/// run(). Never shared between runs. +#[non_exhaustive] +pub struct RunContext { + // identity + name: String, // run identity, carried on every report/event + start_time: SystemTime, + depth: u32, // model-orchestrated prompt-tool nesting: + // 0 for a root run, parent.depth + 1 per + // hop the MODEL's dispatch initiated (Lua + // tools.call recursion never increments); + // the adapter refuses the call when the + // next hop would exceed max_depth. + // Not resettable from Lua. Always 0 in + // v1 - the adapter that increments it + // defers with the prompt-pack. + + // host-supplied per-run inputs (set before prepare; the + // create-then-enrich ordering: prepare's checks read these) + model: ModelDescriptor, // the current selection (in Workshop, the + // dropdown). Input to the fill function. + // Grows into a catalog or policy in the + // deferred multi-model future - a field + // change, never a signature change. + // (the host-push `offering: Vec` field is deferred - + // see Deferred: the open toolset) + + // prepared artifacts (written by prepare) + model_bindings: ModelBindings, // model satisfaction, journaled + tools: ToolCatalog, // assembled from activated capabilities + tool_bindings: ToolBindings, // alias -> ToolId -> tool, journaled + vfs: VfsRef, // fresh router per run: mounts env.base_vfs + // (shared storage, base claims catch cross-run + // host-file conflicts) + fresh memory backend at + // the store mount (per-run storage, per-run claims). + // NOT an overlay: overlay shares the claims table, + // which is only correct for two views of the SAME + // storage; concurrent runs' stores are different + // storage and must not share claims. + + // run services and options (current RunConfig set) + observer: Arc, + cancel: Option, + client: Option, // overrides Environment.client; for + // per-run fault injection and tests + input: Option>, + ui: Option serde_json::Value + Send + Sync>>, + limits: RunLimits, + debug: Option>, + on_delta: Option>, +} + +impl RunContext { + pub fn name(self, name: impl Into) -> RunContext; + // current RunConfig builder methods renamed on (observer, cancel, + // limits, input_broker, ui, on_delta, debug), plus the per-run + // inputs above (model) +} + +/// The run's model satisfaction: which concrete model each declared +/// role is bound to, and the descriptors of every model this run may +/// use. Written by the fill function at prepare; v1's fill binds every +/// role to the current model ("a little stupid convenience function," +/// user 2026-09-13). Handles resolve label -> id -> descriptor. +pub struct ModelBindings { + roles: /* label -> ModelId */, // the decision, journaled + models: /* ModelId -> ModelDescriptor */, // what this run may use +} + +/// The run's tool bindings. Exact slots fill by identity against the +/// assembled catalog; fuzzy slots fill via the picker (the fill is +/// journaled). +pub struct ToolBindings { /* alias -> ToolId; ToolId -> Arc */ } + +/// The preflight report: what the caller must still satisfy. +/// Skipped optional capabilities are a log line at prepare, not a +/// report field. +pub struct Requirements { + pub unmet_requirements: Vec, // role, which check, required + // vs actual (min_context 200k + // vs 32k; thinking vs Never) + pub missing_required: Vec, // run fails until satisfied +} + +// What prepare can actually check is exactly two things: the context +// minimum and the hard keywords. There is one minimum, not a family of +// minimums - which is why the report field is named unmet_requirements +// rather than "minimums." +pub struct UnmetRequirement { /* role label, which check, required vs actual */ } + +/// Explicit host-built registry of installed capabilities. +/// Linking alone registers nothing. v1 is unversioned: one capability +/// per id. +pub struct CapabilityRegistry { /* private */ } + +impl CapabilityRegistry { + pub fn new() -> CapabilityRegistry; + pub fn register(&mut self, cap: Arc) -> Result<(), RegistryError>; + pub fn get(&self, id: &CapabilityId) -> Option<&Arc>; + // registration-time near-duplicate lint over capability descriptions + // via the picker; the tool prefix-containment check runs at + // assembly, not registration: tools exist only after create(). +} + +pub struct RegistryError { /* kind: DuplicateId */ } +``` + +The interface and its result: + +```rust +/// What the run produced. Domain outcomes (including "the prompt +/// declined") are values, not thrown errors. The variant is for code; +/// the payload is for humans and models. +pub enum RunResult { + Ok(String), // mirrors Result vocabulary; note: patterns + // need RunResult::Ok qualification wherever + // Result is also in scope + Cancelled, + Failure(RunError), // the existing RunError: kinds, Display, + // and source chains already mapped +} + +pub async fn run( + prompt: &Prompt, + args: &str, + ctx: RunContext, +) -> RunResult; +``` + +`RunError` exists today (`promptforge-api/src/execute/error.rs` ~54-58) as a `#[non_exhaustive]` newtype over the internal `Error`, with a stable `kind()` classifier plus `is_cancelled`/`is_retryable` predicates and the cause preserved through `std::error::Error::source`. This plan adds one kind and one accessor: + +```rust +// promptforge-api::execute - EXISTS today; one kind and location() added + +pub enum RunErrorKind { // non_exhaustive, Copy + Parse, // prompt parse / invalid compiled Lua region + Version, // unsupported promptforge: major + Binding, // capability absent, unbindable, or clashing + Completion, // transport / backend / decode + Tool, // dispatched tool failed, unknown, no convergence + Store, // run-scoped store operation failed + Determinism, // claims violation: fatal, not Lua-catchable + Lua, // section Lua phase failed: the prompt has a bug + Quota, // log/instruction quota exhausted + ContextExhausted, // compactor exhausted the context window + Input, // input broker failed a user_input request + Substitution, // {{ }} prose substitution failed + Cancelled, // host cancelled (mid-run classification only; + // the interface reports RunResult::Cancelled) + Internal, // invariant failure: the machinery broke + RequirementsUnmet, // NEW: H1 assertion / unmet model + // requirement - the environment cannot + // satisfy this prompt +} + +/// Where a failure lives: a prompt source position or a Rust code +/// position. One generic shape - the RunErrorKind says which world the +/// fault is in, and the path's extension says it again. +/// Note (2026-09-13 verification): Prompt::parse is (input, execution, +/// observer) - the parser learns the prompt's name from the frontmatter, +/// not a parameter, so a frontmatter YAML failure predates the name. +/// path is the frontmatter name when parse got that far, the host's +/// label for the source otherwise. +pub struct SourceLocation { + pub path: String, // prompt name (from its frontmatter) or + // host label, or Rust file (from file!()) + pub line: Option, // 1-based + pub column: Option, // 1-based + pub span: Option>, // byte span, as today +} + +impl RunError { + pub fn kind(&self) -> RunErrorKind; + pub fn is_cancelled(&self) -> bool; + pub fn is_retryable(&self) -> bool; + /// Where the failure lives, when it has a location. Structured, + /// never embedded in the message: kinds are for code, messages for + /// reading, locations for navigation. (2026-09-13 verification: + /// the serde_yaml_ng error is retained as #[source] today - the + /// work is surfacing its location() into SourceLocation, not + /// capturing anything dropped.) + pub fn location(&self) -> Option; // NEW + // Display + std::error::Error::source: the notice and the cause chain +} +``` + +- File and public API changes: + - `shared-promptforge-api`: new `names` and `capabilities` modules; `tools::ToolId` re-based on `GlobalName`; new dependency on `shared-vfs`. + - `promptforge-api`: `ResolutionContext` and `RunConfig` removed, replaced by `Environment`/`RunContext`; `run` signature changes; `PickerResolver` leaves the bind path (models and tools); the picker's own 2-part `ToolId` migrates onto the grammar; `RunErrorKind::RequirementsUnmet` and `RunError::location()` added. + - `promptforge-parser`: `capabilities`, `tools`, `args`, `models` frontmatter keys; parse errors carry line/column (capture the `serde_yaml_ng` location, dropped today). The parsed `Prompt` exposes the FULL declaration to hosts - every model role with its label, keywords, context minimum, and description, and every tool slot with its alias and posture - regardless of how the host will satisfy it (user, 2026-09-13: "I still want parse() on a prompt to return all the frontmatter model stuff"). The parser is a pure function of the source text with no host policy in it: returning only what today's host needs would bake the one-model assumption into the prompt side of the `run()` boundary, sawing off the branch the deferred multi-model work sits on. The full declaration is what `prepare` consumes for requirement checks and what a host UI (the deferred Run Prompt window) renders; the Lua `prompt` reflection global that would expose it to sections is deferred with it. + - `promptforge-lua`: `tools.bind` removed entirely (binding is frontmatter); `models.bind` removed (frontmatter labels auto-bound); `models.default` takes a label; model handles inspectable; `tools.add`/`add_local` in H1 for decision tools; `tools.add`/`always` remain the advertising gate. + - `promptforge-webfetch` / `promptforge-web-search`: combined into the single `promptforge/web` capability (their tools become `promptforge/web/fetch` and `promptforge/web/search`). + - `workshop-sessions`: builds one shared `Environment` at startup (model-free), creates a per-session RunContext carrying the dropdown's current model; `chat.md` gains `capabilities:`, `tools:`, and `models:` frontmatter. + - The prompt-pack capability (DEFERRED 2026-09-13 - "can we defer the prompt-pack?"; the full design stays recorded here and under Deferred: the prompt-pack capability): a capability whose contribution is a directory of prompts, one tool per prompt. A prompt already shares the tool contract - `name` and `description` frontmatter, and the `args:` declaration (or the default) derives the tool's JSON Schema, so args serve human invokers and model-facing advertisement at once. Invocation runs the prompt as a sub-run prepared against the parent run's Environment with a derived RunContext (same registry and base VFS; the parent's model carries over); the sub-prompt's own frontmatter bounds what it activates. Over-exposure policy belongs to the pack author (what goes in the directory, what each prompt declares), not to machinery. The adapter does not validate args: the call's JSON passes straight to the sub-run, whose H1 controls the response (strict field access, or the repair pattern); a sub-prompt hard error maps to a tool error result for the parent, never a parent run failure. The sub-run's `RunResult` text becomes the tool output (Untrusted, guard-wrapped like any model-generated content), so a failed sub-prompt arrives as a readable failure notice the calling model can reason about. The tool is an ordinary bound tool: the model fills schema-constrained JSON, and Lua calls it through `tools.call` with precisely named fields validated by the same schema - one tool, two callers, one schema. (Dispatch does no schema validation today - `tool_loop.rs` ~294-367 passes arguments straight through; generic dispatch-time validation for all tools is not this plan.) +- Data, persistence, failure, security, and privacy constraints: + - Global name rules: kind is encoded by arity - capabilities are exactly two `/`-separated segments (`namespace/pack`), tools exactly three (`namespace/pack/name`), and a tool's first two segments name its contributing capability (containment is total; enforced at assembly since tools exist only after `create`). Namespace is reverse-DNS (`io.github.corp`) or the reserved first-party prefix `promptforge`; segments are lowercase ASCII alphanumeric plus `-`, `_`, `.`; case-sensitive comparison. v1 is unversioned: a `@` in a capability id is a parse error (version pins are deferred; see Deferred). Normalization-collision rejection is deferred to registry scale (see Deferred). + - Alias grammar unchanged: `[A-Za-z][A-Za-z0-9_-]{0,63}` (`promptforge-lua/src/live.rs` ~324); aliases are the only names the model sees - advertising already works this way (`promptforge-api/src/execute/scope.rs` ~94). + - Model keywords are a closed vocabulary - live: `thinking`, `no-thinking`, `frontier`, `fast`, `small`, `creative`, `chat`; unknown keywords are parse errors; hard keywords and the context minimum are checked against the filled model's descriptor, soft keywords document author intent; adding a keyword is a language change requiring a descriptor property to check against or a documented documentary meaning. + - The `ModelBindings`, the `ToolBindings` (including fuzzy fills), and every capability activation are journaled at run start; decision-tool results are journaled like any tool call, so replay consumes recorded verdicts rather than re-rolling them. + - Natural-language guidance is first-class input: soft guidance flows as prose and the author weaves it into instructions; hard gates are interpreted once at the top of the run and the result drives advertising (which bound aliases the model gets to see); typed `args` serve invokers that already hold structured values; both feed the same gate. LLMs translate intent into args; prompts consume args; the model never interprets prose to decide its own tool set inside the deterministic boundary. + - Decision-tool idiom: interpret guidance into flags via a local decision tool (`tools.add_local` with an enum parameter; three no-arg tools as the weak-model fallback), never by string-parsing prose model output; the "unspecified" choice must exist explicitly; the no-call exit is handled in code. + + + + +## Testing Plan + +Parity first: the interface consolidation (step 4) is behavior-preserving and must keep the entire existing suite green. Around it, each step lands with its behavior tests in the same commit, per repository policy. The claims isolation matrix (step 9) is the determinism gate for concurrent runs. + +- Unit: + - GlobalName parse/validation matrix (segment count, charset, normalization-collision rejection, Display round trip); picker `ToolId` migrated onto the grammar. + - Frontmatter: valid matrix, unknown key still rejected, bad capability id, `@` in a capability id rejected (v1 is unversioned), optional flag, args round trip, models round trip, tool slots round trip (exact and fuzzy; the reserved `open` key is deferred, so `deny_unknown_fields` rejects it; a malformed exact path is a parse error), unknown keyword rejected, error locations present and accurate. + - Registry: duplicate id rejection, exact lookup, capability-description near-duplicate lint fires. + - `SourceLocation`: prompt-source positions carry name/line/column; internal faults carry the Rust file/line. +- Integration and end-to-end: + - `prepare` with a fixture capability; missing-required reported; missing-optional skipped and logged; host config reaching `create`; co-activation conflict rejected naming both; every declared role resolves to the current model; unmet requirement reported (e.g. min_context 200k against the model's 32k); `env.run` fails on it with a model-readable notice; implicit prepare via `env.run`. + - Slot filling end to end: an exact slot fills against the assembled catalog; an exact slot whose capability is inactive lands in `missing_required` (the path's prefix names it); a fuzzy slot fills via the picker and the fill is journaled; a fuzzy slot with no match is skip-and-logged when optional; advertise-time gating (arg-gated via `argv`; capability-absent gating awaits the deferred `prompt` global) keeps the tool from the model; alias advertised to model (existing scope tests carry over). + - Workshop session activates a capability, fills its tool slots, calls one end to end; `chat.md` runs on its declared frontmatter. + - Claims isolation matrix: two concurrent runs writing the same store path proceed without conflict; two concurrent runs writing the same host file through the shared base hit a determinism violation. + - Failed H1 assertion produces `RunResult::Failure` with `RequirementsUnmet` and the failure notice; host cancellation produces `RunResult::Cancelled`. + - Args surface: `args` is the exact passed string always and `{{ args }}` renders it, unmodified; `argv` is the parsed JSON or nil (`if argv then` is the malformed test); structured access is `argv.query`; the executor never hard-errors on shape; a declared prompt's H1 strict path errors on missing fields, and the repair path (inference plus a local capture tool) recovers broken JSON from `args`; `argv` assigned in H1 is visible to every downstream section, and assigning `argv` in an H2 section is an error; a default-declared prompt wraps interface prose into `argv.prose` with `args` holding the exact passed string. (The tool-channel cases - a call omitting the optional field arriving as absent (nil), distinguishable from an empty string - defer with the prompt-pack, v1's only tool channel.) + - Prompt-pack (DEFERRED - its tests land with it): a directory fixture installs its prompts as tools with derived schemas; the model calls one end to end; Lua calls one with named fields through `tools.call` (schema-validated); a self-calling prompt is refused at `max_depth` with a clear tool error; a sub-prompt failure arrives as an Untrusted failure notice in the parent's tool result; a malformed model call (missing field, wrong type) is delivered to the sub-prompt's H1 (no adapter rejection): its strict path maps the failure to a tool error result for the parent, its repair path recovers, and the parent run continues either way; a default-declared sub-prompt advertises the default schema (one optional string field named `prose`), a tool call omitting the field arrives as absent (nil), distinguishable from an empty string, and prose at the interface arrives wrapped as `argv.prose` - one spelling on both channels, with `args` holding the exact passed string. +- Regression, security, and performance: + - The full existing suite stays green through the interface consolidation; existing alias/scope behavior tests are preserved. + - Migration: shipped prompts, fixtures, guide examples, and every test using prose resolvers move to frontmatter tool slots and model labels. + - The picker crate's own suite stays green after its `ToolId` migration. +- Exit criteria: + - All acceptance criteria in the Functional Specification pass; the full workspace suite, clippy, fmt, and doc builds are green; the guide covers every user-facing change (the four frontmatter keys, args/argv, substitution, model roles, tool slots and advertising, the prepare flow, the bind removals with migration examples, and the deferred-feature notes) per the docs step (step 18). + + + + +## Decision Record + +- Decisions: + - Capabilities, not packs, are the frontmatter INSTALLATION unit - a capability is code that runs and makes services available (tools, mounts, prompt fragments, clients); you cannot tell from the name what tools you get, that is what the capability's documentation is for; a plugin can install multiple capabilities and the prompt names the ones it wants. (Tools ARE named in frontmatter since the 2026-09-13 binding move - as slots with aliases under `tools:`, which is binding, not installation; see the frontmatter-binds decision.) User: "the front matter shouldn't be naming tools or tool packs. Instead, it should be naming capabilities." + - Frontmatter installs AND binds, H2 scopes - the YAML is the whole contract: `capabilities:` install, `tools:` bind, `models:` declare, `args:` type. There is no `tools.bind` in Lua. Binding and advertising are separate facts. Binding needs no run-time inputs: everything a binding decision could depend on (the frontmatter, the args, which capabilities activated) is known by prepare time - binding was already H1-only even before this plan, so no information appears later that could change it. What remains for run time is advertising: conditional availability is expressed in the Lua at advertise-time (`tools.add`/`always`), arg-gated via `argv` (capability-gated advertising awaits the deferred `prompt` global). H1 keeps local tool creation (`add`/`add_local`), model use, and the `argv` repair pattern. User: "the reason I said the YAML never goes to the level of individual tools is because I wanted it in the Lua. But if we are getting rid of tools.bind and moving it to the YAML then that is acceptable to me" and "my intuition tells me to normalize the contract through the YAML and treat everything the same. a user should be able to know progammatically what tools a prompt wants." + - Global names encode kind by arity - capabilities are `namespace/pack` (2 segments), tools are `namespace/pack/name` (3 segments), and a tool's first two segments name its contributing capability (containment is total). The problem this solves: the earlier uniform 3-part grammar let `promptforge/core/web` (a capability) and `promptforge/core/web_search` (a tool) sit as same-shaped siblings differing only by suffix, and names travel to journals, errors, and discovery results where the YAML-key context that disambiguates them is absent; counting segments now tells any reader the kind, and the old stutter (`core/web` next to `core/web_search`) is gone. Reverse-DNS namespaces, MCP-registry convention, never URL-shaped so names are not mistaken for locators. Fine-grained capabilities over a shared `core` pack. User: "reverse-dns is fine I guess"; "how about: capability: namespace/pack, tool: namespace/pack/name"; "promptforge/web obviously." + - Web search and fetch ship as one bundled capability - `promptforge/web` contributing `promptforge/web/search` and `promptforge/web/fetch` - because research prompts want them together or not at all. (Dashes are legal in name segments: the charset is lowercase alphanumeric plus `-`, `_`, `.`.) User: "A new capability promptforge/web-tools (can we use a dash?) which includes web_search and web_fetch, and make chat.md have it as a capability, bind both tools, and make them available to the chat" - renamed per the arity grammar. + - The picker leaves the run-time binding path - exact slots replace invisible semantic matching because a prompt that needs bashkit needs bashkit, not a fuzzy match; the picker survives as the journaled prepare-time fill function behind fuzzy slots, and later powers the discovery capability when that lands. User: "if someone needs bash kit, they need bash kit. They don't want to do a fuzzy string match." + - The picker is machinery, not a seam - one picker, built internally, never caller-configurable, absent from the Environment. User: "there's not going to be multiple tool pickers. There's going to be one tool picker... this is not something that you should be able to configure." + - Tool slots have two live postures - exact (alias -> global path) and fuzzy (alias -> `want` description, filled by the picker at prepare, journaled); the third posture, open (the prompt accepts host-offered tools), is deferred (user, 2026-09-13: "lets defer the open toolset" - see Deferred: the open toolset). Exact and fuzzy coexist because they serve different needs: exact for the prompt that knows, fuzzy for the prompt that does not. The wins that justified reversing "no tools: key ever": tool availability is preflighted by prepare before any model call instead of discovered as a Binding failure mid-run, and the contract becomes one uniform pattern - capabilities, models, tools all declared in the YAML, exposed by parse, filled at prepare, journaled. User: "we need to have a way to do both things: 1. specify an exact tool by its precise path 2. specify a tool by fuzzy capability." + - Host-push offerings (DEFERRED 2026-09-13 - "lets defer the open toolset"; the design is recorded under Deferred: the open toolset) - an agent prompt declares the open posture and the host arms the run: the offering (which installed capabilities to give it) is host policy, carried per-run on the RunContext, journaled; offered tools derive aliases from name segments; the prompt adapts via reflection (`prompt.tools`, `open_tools`) - offered nothing, it runs chat-only. The trust story: host-push is the operator arming their own agent, and the capability-level gating (high-risk capabilities, the subagent checkpoint) applies to offered capabilities exactly as to declared ones. User: "what if I am implementing an agent, and I want the host to be able to give it more tools, or less tools, and the agent makes do with what it is offered." + - `open_tools` is the Lua and substitution surface of the offering (DEFERRED with the open toolset) - a table of id/alias/description per offered tool, iterable for code-side filtering and renderable via `{{ open_tools }}` for prompt-mediated selection (the decision-tool idiom's dynamic variant: the choice returns as a tool id string through a local tool, empty string is the explicit no-match, an id outside the batch is treated as no-match; journaled like any tool call). The batch is plain data and passes to sub-prompts through args as JSON. User: "the Lua should have a way to access the batch, and each element of the batch, and then do inference on it" with the subagent choose_tool example. + - Progressive discovery is an advertising problem, not a binding problem - binding stays static; what progresses is what the model knows. The discovery capability (deferred) contributes a search tool over the run's catalog; dispatch should reject unadvertised aliases (enforced hiding, so injected content cannot talk the model into a Lua-only tool), and a search result marks its matches advertised for the rest of the run; the growth is journaled through the search call. The motivation is context economy: capabilities like bashkit carry 142 commands, and advertising every schema drowns frontier models - advertise the workflow backbone, let the model search the long tail. Evidence question for implementation: whether model dispatch today validates against the advertised set or the bound set. + - Models are declared requirements, host-satisfied - frontmatter declares roles under prompt-local labels with descriptions and a context minimum; the caller satisfies them. User: "the tool will advertise the models that it wants, and then it's up to the caller to satisfy it... a perfectly valid solution is for the caller to just use one model for all three." + - The host side is ONE model - no catalog, no role mappings, no selection machinery at the executor interface; every role resolves to it, and hard keywords and the context minimum are checked against its descriptor and reported rather than shopped for. The simplification lives entirely at `run()` and above: from the prompt's perspective the infrastructure is fully built out - it declares roles as if a catalog shopped for them, and the one-model host is the trivial satisfaction of that full contract. The gateway's model list stays in the host UI layer (the Workshop dropdown menu) and never crosses the interface. This supersedes the proposed preference-ordering selector and dissolves the "how does the host choose" problem: the executor never chooses - the human chose, at the dropdown, outside the executor. User: "I want the frontmatter model schema, but on the host side of it I just want a single model. No model catalog or any of that, just one model" and "from the prompts perspective it thinks all the infrastructure is built out. it thinks there's a model catalog, etc. its just that at the executor's run() call and above, there is only one model descriptor. And it gets used for all the roles." + - Two objects by rebuild-ability - the Environment holds what is never rebuilt (registry, client, base VFS, max_depth); the RunContext holds what can change per run, including the current model. The model is neither an Environment field nor a `run` parameter; it rides the per-run object, where the deferred multi-model future grows it from one descriptor into a catalog or policy - a field change, never a signature change. A dropdown switch simply takes effect on the next run's context. The insight that forced this: the model's lifetime is neither deployment-constant nor prepared-per-run - it is SELECTION state, chosen at invocation exactly like `args`, and nobody would file `args` in the Environment. User: "I dont like this 'rebuild the environment.' I want to have this: 1. Some object which holds the things that are never rebuilt 2. Another object which holds the things that can change per-run" tempered by "I dont want &model to be a parameter to run. That bakes the single-model design too deeply." + - Model satisfaction is a fill function over slots - the parsed prompt exposes declared roles as a Vec of slots; prepare's fill function maps each slot to a concrete model; v1's fill is deliberately trivial (every slot gets the current model) and the result is journaled as `ModelBindings` (roles label->ModelId, models ModelId->ModelDescriptor; handles resolve label->id->descriptor). The seam is the point: the structure is general from day one - a table of models and a map of roles - and only the content is trivial, so multi-model satisfaction arrives as a smarter fill function, a policy change rather than a structural one. User: "we're just going to put a little function in there, a little stupid convenience function that just fills in all three choices with the same model. So the infrastructure is kind of there" and "ModelBindings." + - The catalog is a menu, not a preference - the gateway holds every model the operator wants reachable (four providers, ten-plus frontier models, all in the Workshop dropdown); membership says nothing about which model a prompt gets, and the menu never reaches the executor. User: "I want them all available, because in the Workshop IDE I want to be able to switch back and forth between different models." + - `env.run` fails on unmet model requirements with a model-readable notice - the zero-burden path refuses a prompt whose declared requirements the one model cannot meet, and the failure string is content a calling model can reason about (it arrives as tool output when the prompt runs as a sub-run tool); the multi-step path lets a host read the report and proceed deliberately. The report vocabulary is `unmet_requirements`, not "minimums" - the checkable surface is exactly the context minimum plus the hard keywords, and the names say so. User: "if there is no model that meets the minimum then env.run should fail with a suitable string that a model can read" and "the only 'minimum' here that I can see is context size. so why do you talk about minimums in the plural?" + - Model keywords are a closed vocabulary (`thinking`, `no-thinking`, `frontier`, `fast`, `small`, `creative`, `chat` live; `multimodal`, `visual` deferred) - hard keywords are checked against the host's one descriptor, soft keywords document author intent. User: "there should be a set of keywords that frontmatter can attach" and "we dont need the multimodal for now just mark it in the design and defer it. You will need chat role." + - `models.bind` is removed - frontmatter labels are auto-bound and directly usable. User: "models.bind has to go." + - Model handles are inspectable and report the full actual capability set - a model asked for `fast` may also have vision, and a section may use what it discovers. User: "the model might have additional capabilities, and the Lua should be able to reflect its own metadata." + - Lua reflects on the prompt's own metadata via a read-only `prompt` global (DEFERRED 2026-09-13 - "defer `prompt` keep argv"; the design is recorded under Deferred: the `prompt` global) - the prompt adapts to its own contract without duplicating constants between YAML and Lua. Same user statement. + - Optional capabilities - absent optional capabilities skip-and-log; user-specific config is host-supplied, never named in the prompt; Lua adaptation to an absent optional awaits the deferred `prompt.capabilities` reflection global (the dedicated probe is likewise deferred) - in v1 an absent optional's slots are simply unfilled, and advertising an unfilled alias is an error, so v1 gating is arg-based (`argv`) only. User: the Stalker (the operator's research prompt; see Project Survey terminology) "can be run with or without MCP." + - Natural-language guidance is first-class input - soft guidance flows as prose; hard gates are interpreted once at the top of the run and the result drives advertising (binding is frontmatter now, so the verdict gates `tools.always`/`tools.add`, not slot filling). User: "the user has to be able to use natural language to guide the behavior of the prompt." + - Args and argv - `args` is the exact passed string, always (unchanged from today; legacy prompts and `{{ args }}` are untouched by definition), and `argv` is the parsed JSON on success, nil otherwise (`if argv then` is the malformed check; the repair pattern works from `args`). No container, no alias, no metatable magic, and full backward compatibility because `args` never changes. The executor never hard-errors on shape; H1 chooses strict (access declared fields; missing fields error) or tolerant (inference repair with a local capture tool from `args`). The declaration, never the invocation channel, determines the shape: the tool channel conforms by construction (the tool schema derives from the declaration), and prose at the interface of a default-declared prompt is wrapped into the default shape (`{ prose = "" }`), so the spelling is `argv.prose` on both channels - named `prose`, not `args`, because `args.args` names args twice. User: "args = the raw string always; argv = the json on success, nil otherwise"; "I dont want { args = \"\" } because then args is named twice. How about { prose = \"\" }?"; "it can't be a hard error. we want to allow a prompt to receive broken JSON and then let the H1 handle it... it's under the prompt's control"; "Models never do tool calls with naked strings" (correct - tool parameters are always JSON objects, which is why every prompt exported as a tool carries an object schema); "I don't like how there are now two kinds of args depending on the harness." + - Every prompt has an args declaration - no freeform prompts; an omitted `args:` key gets the default (one optional string field named `prose`), the advertised tool schema is always the real declaration, and absent is not the empty string. User: "there must be no freeform prompts. if a prompt leaves out args in the frontmatter, we default it to [one optional string field]" and "I think the field should be optional, that is the tool can be called completely absent args. This is different from an empty string." (Their draft wrote `default: true` on the string field; the recorded semantics are optional-with-no-default, per their answer. The field is named `prose`, not `args` - "args is named twice" otherwise.) + - Args substitution needs no new syntax - `{{ args }}` renders the raw string (unchanged from today), `argv` joins the substitution namespaces, dotted paths index `argv` (the parsed value), dotted-into-scalar stays a catchable error. User: "we have to determine the syntax. what does {{ args }} do in prose? what about args.text? args.val?" + - Decision-tool idiom - interpretation happens through a local tool call (enum parameter; three no-arg tools as the weak-model fallback), never string-parsing. User: "we would offer a local tool using tools.add_local, and the model would call the tool with the right parameter. or maybe offer 3 different tools, taking no args, corresponding to the choice." + - H1 allows adding tools, as ordinary section behavior - `tools.add` and `tools.add_local` work in H1 exactly as in any section, so decision tools run in H1 and the run's shape is fixed before the walk; the H2-only restriction was an artifact of the recording-phase model. A decision tool created in H1 is H1-local: spent during H1's own model loop, its verdict captured into `argv` or a `var`, dead by the walk. 2026-09-13 verification: `models.loop` is confirmed ABSENT in H1 (section-only shim today) and `add_local` is absent rather than stubbed - so step 13 includes installing a loop shim and `add_local` in H1, not just checking. User: "H1 should allow adding tools" and "tools.add and tools.add_local should work like normal in H1." + - H1's only privilege is `argv` writability - tool binding moved to the YAML (2026-09-13), and `tools.add`/`add_local` are universal section behavior that simply work in H1 too (a decision tool created in H1 is H1-local: called during H1's own model loop, its verdict captured into `argv` or a `var`, dead by the walk). `tools.always` and `models.default` are static prompt-wide facts parked in H1 by convention, not privilege. The implementation says the same thing: ONE install path for every section - `SectionVm::for_section(shared, section_index)` with H1 as section 0, the `argv` writability gate as the only special case; the H1 control stubs delete (`call`/`fanout`/`jump` work in H1 as in any section) and the live H1 binding machinery (the live.rs accumulator, the stubs, the fresh live models table) is removed entirely, since bindings arrive pre-filled from prepare (user, 2026-09-13: "can you perhaps use the same function to set up H1 as you do the other sections, but just pass the section number so it can special-case the argv?" and "I rather reduce code"). The reader-value rule stands and strengthens: a reader looks at the frontmatter and knows every capability, tool, and model the prompt wants. Any future H1 specialness needs a reader-value justification ("a reader needs to know this happens only here"). User: "I am trying to move H1 towards not being special" tempered by "I do like H1 being a little special though. Being able to look at a prompt, and know that argv can only change in H1 has value" and "tools.add and tools.add_local should work like normal in H1." + - Prompts can masquerade as tools (DEFERRED 2026-09-13 - "can we defer the prompt-pack?"; see Deferred: the prompt-pack capability) - a prompt-pack capability makes a directory of prompts available as tools (and publishable via MCP), the `args:` block derives the tool schema, sub-runs inherit the parent run's Environment, and Lua invokes them through `tools.call` with precisely named fields. User: "it should be possible to make the prompt masquerade as a tool... a capability is a directory full of prompts that are made available to other prompts as tools" and "it also has to be available to the Lua... they need to be able to name the fields." + - Prompt-tool nesting is depth-guarded, and depth accrues only on model-orchestrated hops - `Environment.max_depth` (default 3, deliberately low) copied into every `RunContext`; the sub-run adapter increments `depth` only when the MODEL's tool dispatch made the call, never when Lua recurses through `tools.call`, because Lua recursion is deterministic operator-written code (a bug there is an ordinary programming bug, visible in source and journaled), while model-driven delegation is the runaway-autonomy risk `max_depth` exists to bound. Read `depth` as "layers of model-decided delegation." Not resettable from Lua. The FIELDS are live from the interface consolidation (user, 2026-09-13: "keep the depth/max_depth"); the enforcement arrives with the deferred prompt-pack's sub-run adapter, so `depth` is always 0 in v1. Implementation consequence recorded for the deferred work: the adapter must learn the call's origin (model dispatch vs Lua `tools.call`), and dispatch passes arguments straight through today, so an origin flag on the call path is new machinery. Known gap, accepted: Lua recursion can still burn money because each sub-run gets fresh quotas; if a backstop is ever wanted, a total-sub-run-count budget per root run covers both origins - a separate knob, not `depth`. User: "there needs to be a max depth, and I think that number should default to like 3 or 5. It's low... a feature of the environment... copied into the run context" and "depth should only accrue when the model orchestrates it. when Lua recurses, we should not even count it. Because it is completely under control of the operator." + - The interface consolidates then splits by lifetime - `Environment` (deployment-level, `Sync`, shareable across concurrent runs) and `RunContext` (per-run, owned by the executor, carrying name, start_time, the run's VFS). User: "Environment: an object which is safe to share between multiple concurrent calls to run(). A run-specific object RunContext which is owned by the executor and has run-specific variables (like start_time)." + - Multi-step construction - create the RunContext from the Environment with the per-run inputs -> `prepare` -> adjust only flagged items -> `run`; the zero-burden path is `env.run(&prompt, args, ctx)`. User: "a multi-step construction and launch of the executor... do you see a way to relieve the caller of burden?" + - The run's identity field is `name: String` - shorter, and stops overloading "execution." User: "rename it to name: String." + - The interface is infallible and returns `RunResult` - domain outcomes are values; `RunError` is reused with one new kind (`RequirementsUnmet`); cancellation is a top-level variant. User: "instead of delivering it as a Rust error, what if we deliver it as a 'the prompt failed, and here's why' no different than if it produced that result via inference" and the `RunResult { Ok, Cancelled, Failure(RunError) }` shape. + - The success variant is `Ok(String)` - mirrors Result vocabulary; the pattern-qualification tax (`RunResult::Ok` vs `Result::Ok`) is accepted. User: "change Success(String) to Ok(String)." + - Error locations are structured and generic - `SourceLocation { path, line, column, span }`, where path is a prompt name or a Rust file; the kind and the extension say which world the fault is in. User: "all you had to do was just change the name of the field so it is generic. e.g. path instead of prompt. we dont need Prompt and Rust." + - The store is always present - it is how data moves between sections and how fanout coordinates; interior primitives are never capabilities. User: "Store is always present, its a core feature of the PromptForge language because its the way to move data between sections and handle fanout." + - Lua `fs` surface is a capability - language surface that reaches outside the run is opt-in. User: "filesystem is a good example, the fs table in the Lua. It is not assumed, you have to ask for it." + - Lua surface is in-process only in v1 - exporting the Lua API through the ABI is rejected; the addon ABI has exactly one verb (`call`). User: "I dont think for example fs can be in a DLL, as this would mean exporting the entire C Lua API through abi_stable?" and "agree with ': Lua surface is in-process only.'" + - Per-run VFS is a fresh router, never an overlay - claims are shared per storage, not per namespace; concurrent runs' stores are different storage. User: "if the store mount is overlaid but the Vfs has one Claims table for all VfsRef how will different store in different runs work properly?" + - Error and status messages are designed assuming model consumption - any string the system produces may be read by a model (a sub-run's failure notice arrives as tool output the calling model reasons about), so messages are concise, factual, and self-contained; the rule lands in the root `AGENTS.md` Principles section. User: "the plan should add a line to an AGENTS.md with the rule that error messages or status messages should be designed with model consumption assumed." + - v1 scope is tools-only contributions, unversioned names, and a slim Requirements report - every cut is recorded under Deferred rather than removed, and each is a field or variant added later for free. User: "I take all your recommendations but I do not want anything removed from the plan. Put it in Deferred or Out of scope." +- Rejected alternatives: + - An `Environment::from_gateway` constructor - closed as dissolved 2026-09-13 (user: "drop the question"): the model-free Environment left it nothing to construct beyond the client, which is thin sugar over `GatewayClient::new`, and the catalog fetch is a host-UI concern (the dropdown menu) that hosts already perform. User's rationale: "sugar can always be added." Revisit if a host ever needs descriptor and client provably from one credential pair. + - The model as a `run`/`prepare` parameter - rejected because it bakes the single-model design into the interface signature; the per-run object carries the model so the deferred multi-model future is a field change, never a signature change. Revisit never (the signature is the contract that survives). + - Rebuilding the Environment on model-selection change - superseded same-day by the two-object rule; the model rides the per-run object and a selection change takes effect on the next run. Revisit never. + - H1-only tool binding (the original "frontmatter installs, H1 binds" split, with its "no tools: key ever" corollary) - superseded 2026-09-13 when binding moved to the YAML; the rule existed to keep binding in the Lua, and the user accepted the move once `tools.bind` was going away entirely. Revisit only if frontmatter binding proves lossy in practice. + - The picker as a caller-provided seam in the interface - rejected because there will only ever be one picker; revisit if a genuinely different resolution engine ever exists. + - Prose model binding - rejected because model roles are declared and host-satisfied; revisit never for binding, though the picker survives as internal lint and a deferred discovery capability. + - The Boundary LLM as a system component - not implemented, not deferred (user, 2026-09-13: "we are not implementing a 'Boundary LLM'"). External LLMs may translate intent into args upstream of the interface, but no boundary component exists in this design; the deterministic run boundary has exactly one kind of model consumer, the sections. Revisit never. + - `Result` with separate `FailureKind`/`RunFault` types - rejected because it duplicates the existing `RunError`; revisit if hosts demonstrably need the domain/infra split above the kind level. + - A `SourceLocation` enum with `Prompt`/`Rust` variants - rejected as taxonomy for its own sake; revisit if a location kind ever needs fields the generic shape cannot carry. + - `overlay()` for the per-run store mount - rejected because overlay shares the claims table, which is only correct for two views of the same storage; revisit never (semantics, not preference). + - Exporting the Lua C API through `abi_stable` - rejected because it would not create a boundary between host and DLL; it would fuse them (the DLL reaching into the host's VM is a merger of the two, with none of the isolation a boundary provides). The declarative bridge is the recorded escape hatch. + - Capability interfaces (dependency injection for multi-provider services) - deferred, not rejected; revisit when a third provider of one service appears. + - Domain-first (URL-order) naming - not chosen because pack names are identities, not locators; revisit only if a future registry serves packs by that exact name as a URL. + - The args framings explored and abandoned en route - three-kinds (freeform/valid/broken), the string-like userdata with `.val`/`.valid`, the prose wrapper visible in Lua, the envelope (`args = jv["args"]` - breaks the natural case: `{"query": "x"}` would yield nil), the `sys.args_json`/`sys.args_raw` metadata, the two-field container (`args.json`/`args.raw`), the `args:raw()` accessor (a method cannot be called on nil), and the executor strict gate (rejected: "it can't be a hard error... under the prompt's control"). Revisit never individually; the final model (args/argv) is their synthesis. +- Assumptions, risks, and notes: + - `ModelDescriptor` carries `context` and `thinking` today; a modalities field may not exist - verify before assuming `multimodal`/`visual` can filter. + - Removing prose binding breaks shipped prompts, fixtures, guide examples, and every test using prose resolvers; the migration is its own step (step 15), not an afterthought. + - The picker's own 2-part `ToolId` (`promptforge-tool-picker/src/catalog.rs`) must migrate onto the grammar so lint/discovery speak the same names. + - `promptforge_vfs::empty()` installs a store mount; the Environment's base must be built without it (host roots only), or the base's store mount is dead weight shadowed by every run. + - Capability activation order is frontmatter declaration order; a capability cannot see another capability's mounts during `create`. + - The `RunResult::Ok` variant shadows `Result::Ok` in patterns; qualification is required wherever both are in scope. + - `Environment` must be `Sync` and shareable across concurrent runs. + - The software is pre-release with first-party hosts only (user, 2026-09-13): there are no external consumers to defend against, so design choices that trade simplicity for protection against imagined third-party misuse are premature; optimize for the builders. + +### Deferred and Out of Scope + +- Deferred: the `HostVfs` sabi facade for DLL addons (the `addon_dll_abi` plan; the live `RunServices.vfs` is what the host-side adapter wraps); revisit when the addon plan executes. +- Deferred: the declarative Lua-surface bridge (data schema plus generic host bridge over the addon `call` ABI); revisit if a DLL ever needs Lua surface. +- Deferred: addon ABI types (`AddonModule`, `ToolDescriptor`, `Completion`, `AddonToolOutput`, `AddonToolError`, `ABI_VERSION`) - owned by the `addon_dll_abi` plan's `promptforge-addon-api` crate, unchanged by this plan. +- Deferred: capability interfaces (a `search-provider` interface satisfied by multiple capabilities); revisit when a third provider of one service appears. +- Deferred: the discovery capability (`promptforge/discovery`, the picker as an ordinary optional capability) and progressive tool discovery: it contributes a search tool over the run's catalog; progressive discovery is an advertising problem, not a binding problem - dispatch rejects unadvertised aliases and a search result marks its matches advertised for the rest of the run, journaled through the search call; the tool resolves the catalog lazily at call time (capability `create` order precedes catalog assembly). Revisit when exploratory sessions need prompt-facing discovery. +- Deferred: version-qualified coexistence (two majors of one capability active in one run, WIT-style). Revisit when a consumer needs two majors at once; note the `@major` pin itself is deferred with versioning (below), so coexistence work starts from there. +- Deferred: `multimodal`/`visual` model keywords and the `ModelDescriptor` modalities field they would filter on; revisit when a prompt needs modality-based satisfaction (user, 2026-09-13: explicit deferral). +- Deferred (2026-09-13 scope review; user: "I take all your recommendations but I do not want anything removed from the plan"): + - Non-tool contribution parts: `MountRequest`, `PromptFragment`, `LuaNamespace`/`LuaFunction`/`LuaHandler`, and the VM-construction seam that installs capability-provided Lua globals. v1 `Contribution` is tools-only. Revisit when the fs, agents-md, or MCP capabilities land - they are the consumers. + - Bridge capabilities and the `user-input` conversion: `RunServices.input`, the `ServiceGap` report field, and the `promptforge/user-input` capability. `user_input` works today as a built-in; revisit when MCP (the real bridge consumer) lands. + - Versioning: `PackId`, `Capability::version`, `@major` pins in frontmatter, latest-minor satisfaction, and `Registry` major-aware lookup. v1 names are unversioned; a `@` in a capability id is a parse error. Revisit when the first second version of anything exists - adding an optional pin later is backward-compatible. + - The normalization-collision rejection in `GlobalName` and `RegistryError`. Revisit at registry scale (multiple third-party packs). + - The `skipped_optional` report field - skipped optionals are a log line at prepare. Revisit if a host needs them programmatically. +- Deferred: multi-model satisfaction - a smarter fill function writing `ModelBindings` with more than one descriptor (per-label role mappings and preference ordering over filtered survivors live behind that seam). Superseded by the trivial fill (2026-09-13); the prompt-side `models:` schema already supports it, and the `RunContext.model` field grows into a catalog or policy - a field change, never a signature change. Revisit when a host needs different roles routed to different models. +- Deferred: the Run Prompt window (user proposed 2026-09-13 with "maybe," deferred same day): a dedicated Workshop window rendering the selected prompt's frontmatter as a form - the current model checked against each role's requirements, args fields, input/output file pickers, a Run button gated on a clean `Requirements` report. It consumes the contract rather than shaping it, so nothing in this plan gates on it. Revisit when Workshop needs a first-class run UI beyond the Agent window. +- Deferred: the dedicated optional-capability probe (`caps.available` / `tools.try_bind`) - deferred alongside the `prompt` reflection global; when `prompt` lands, `prompt.capabilities` reports what activated and a probe would be redundant surface. Revisit the two together if reflection proves insufficient in practice (user, 2026-09-13: explicit deferral). +- Deferred: the `prompt` reflection global (user, 2026-09-13: "defer `prompt` keep argv") - a read-only Lua global exposing the prompt's own frozen frontmatter: `prompt.name`, `prompt.models`, `prompt.args`, `prompt.capabilities`, and `prompt.tools` (the slots and what filled them). It does not exist today and this plan does not add it. Consequence for v1: sections have no fill-state or activation-state reflection, so conditional availability is gated on `argv` alone and advertising an unfilled optional slot's alias is an error the author must avoid by declaration discipline. The parser still exposes the FULL declaration on the parsed `Prompt` (hosts and `prepare` consume it; only the Lua surface defers). Revisit when a prompt needs to adapt to its own contract at run time - the Stalker's with/without-MCP adaptation is the motivating case. +- Deferred: the open toolset (user, 2026-09-13: "lets defer the open toolset") - the open posture (`tools: { open: true }` reserved key), the host-push `offering: Vec` RunContext field, the offering merge into the catalog at prepare with aliases derived from tool name segments (collision first-wins, logged), the `open_tools` Lua global and `{{ open_tools }}` substitution, and the dynamic choose_tool idiom (the choice returns as a tool id string through a local tool, empty string is the explicit no-match, out-of-batch is no-match). The full design and its rationale stay recorded in the Decision Record (host-push offerings, `open_tools`), annotated deferred. In v1 `deny_unknown_fields` rejects the `open` key, so a prompt cannot silently half-declare the posture. Revisit when a host implements an agent prompt that must make do with whatever tools the operator arms it with. +- Deferred: the prompt-pack capability (user, 2026-09-13: "can we defer the prompt-pack?" - yes; it is a leaf, nothing else in v1 consumes it) - the directory-of-prompts capability (one tool per prompt, schemas derived from the `args:` declarations), the sub-run adapter (prepare against the inherited Environment, `CancelHandle::child()`, no adapter-side args validation, sub-run `RunResult` text as Untrusted guard-wrapped tool output, a sub-prompt hard error as a tool error result for the parent), typed Lua invocation through `tools.call`, and MCP publishing. The depth fields stay live (user, 2026-09-13: "keep the depth/max_depth"): `Environment.max_depth` and `RunContext.depth` exist from the interface consolidation, and `depth` is always 0 in v1 because the adapter that increments it defers with the pack. When the pack lands, `depth` accrues only on model-orchestrated hops (user, 2026-09-13: "depth should only accrue when the model orchestrates it... when Lua recurses, we should not even count it"), which requires an origin flag on the tool-call path (model dispatch vs Lua `tools.call`) - new machinery, since dispatch passes arguments straight through today. The full design stays recorded in the Decision Record (prompts masquerading as tools, depth guarding) and the feasibility verification stays in the Project Survey (nested `run()` is deadlock-free; the pack depth counter is required because `MAX_CALL_DEPTH` counts section chains only). Consequence for v1: prompts are not tool-exportable in practice (the schema derivation has no consumer), so the tool-channel args tests defer with it. Revisit when a host wants to publish prompts as tools - MCP exposure of a prompt directory is the motivating case. +- Out of scope: `calls:` frontmatter and recursive preflight over called prompts - the user has other ideas for `call`. +- Out of scope: the durable platform tier (event-sourced replay, workers, control plane, multi-tenancy) - a future host concern, not this plan. + +Every deferred declaration, collected (none of this is built by this plan; the live shapes leave room for all of it): + +```rust +// ---- Deferred declarations ---- + +// -- Versioning (revisit: the first second version of anything) -- + +/// Pack identity: namespace/pack plus an immutable semver. +pub struct PackId { /* GlobalName prefix + semver::Version */ } + +pub trait Capability { + // ... live methods ... + fn version(&self) -> &semver::Version; // joins the live trait +} + +impl CapabilityRegistry { + /// Latest registered minor within the pinned major. + pub fn get(&self, id: &CapabilityId, major: u32) -> Option<&Arc>; +} + +// GlobalNameError and RegistryError each gain NormalizationCollision +// (revisit: registry scale - multiple third-party packs). + +// -- Non-tool contribution parts (revisit: fs / agents-md / MCP land) -- + +pub struct Contribution { + pub tools: Vec>, // live in v1 + pub mounts: Vec, // deferred + pub fragments: Vec, // deferred + pub lua: Vec, // deferred +} + +pub struct MountRequest { + pub prefix: String, // e.g. "/workspace" + pub backend: Box, // shared-vfs backend + pub read_only: bool, +} + +pub struct PromptFragment { + pub name: String, + pub text: String, + pub trust: OutputTrust, // AGENTS.md content arrives Untrusted +} + +/// Lua surface is data plus native JSON handlers. promptforge-lua +/// materializes each function into a real Lua global at VM creation. +/// Handlers are synchronous leaf functions; anything needing the +/// coroutine yield protocol is exposed as a Tool instead. +/// DLLs cannot supply handlers (closures cannot cross the ABI), +/// which makes the in-process-only rule structural, not policy. +pub struct LuaNamespace { + pub name: String, // e.g. "fs" + pub functions: Vec, +} + +pub struct LuaFunction { + pub name: String, // e.g. "read" + pub description: String, + pub schema: serde_json::Value, // JSON Schema for args + pub handler: Arc, +} + +pub trait LuaHandler: Send + Sync { + fn call(&self, args: serde_json::Value) -> Result; +} + +// -- Bridge capabilities (revisit: MCP, the real bridge consumer) -- + +pub struct RunServices { + // ... live fields (vfs, cancel) ... + pub input: Option>, // deferred: bridge + // capabilities wire to this +} + +pub struct Requirements { + // ... live fields (unmet_requirements, missing_required) ... + pub skipped_optional: Vec, // deferred: a log line in v1 + pub service_gaps: Vec, // deferred +} + +/// A capability activated without its host service installed +/// (e.g. user-input with no broker): degrades, warned. +pub struct ServiceGap { /* capability id, which service, what the degradation is */ } + +// -- The open toolset (revisit: a host arms an agent prompt per run) -- + +pub struct RunContext { + // ... live fields ... + offering: Vec, // deferred: host-push arming, consumed + // when the prompt declares the open + // posture (`tools: { open: true }`) +} + +// -- DLL addon facade (owned by the addon_dll_abi plan) -- + +/// FFI-safe VFS handle minted per run for DLL addons: a vtable over +/// Access verbs, RArc-backed, dead-flagged on abandonment. The live +/// RunServices.vfs is what the host-side adapter wraps. +#[sabi_trait] +pub trait HostVfs { + fn read(&self, path: RString) -> RResult, RString>; + fn write(&self, path: RString, contents: RVec) -> RResult<(), RString>; + // append, remove, exists, glob, list, stat, grep, mkdir, ... +} + +// -- Declarative Lua-surface bridge (revisit: a DLL needs Lua surface) -- + +/// The DLL-facing version of LuaNamespace: pure data, materialized by a +/// generic host bridge that routes each call through the addon `call` +/// ABI. A translation of the deferred LuaNamespace design, not a redesign. +pub struct LuaNamespaceDecl { + pub name: RString, + pub functions: RVec, // name, description, schema_json +} + +pub struct LuaFunctionDecl { + pub name: RString, + pub description: RString, + pub schema_json: RString, +} + +// -- Model modalities (revisit: a prompt needs modality satisfaction) -- + +impl ModelDescriptor { + /// Enables the deferred multimodal/visual keywords. + pub fn modalities(&self) -> &[Modality]; +} +``` + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked` (default member is `crates/gateway` only, so a plain build compiles on a fresh clone with no CUDA toolkit or Tauri system packages); desktop app: `cargo build --locked -p workshop` +- Focused test command pattern: `cargo nextest run --locked -p `; a single integration test: `cargo test --locked -p --test it ` +- Component test command pattern: `cargo nextest run --locked -p ` (workshop-server also runs a `--features headless` pass) +- Full-suite test command: `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 command: `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`); supply chain: `cargo deny check` and `cargo audit` +- Formatter check command: `cargo fmt --all --check` +- Docs command: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server` with `RUSTDOCFLAGS="-D warnings"`; user guide: `mdbook build guide` +- Test placement and naming conventions: unit tests live in `#[cfg(test)]` modules, with larger suites in a `src/tests.rs` module (promptforge-lua, promptforge-parser, promptforge-store); integration tests are one `it` target per crate rooted at `tests/it/main.rs` with a module tree beside it (a few crates use flat named files directly under `tests/`); executor prompt fixtures are Markdown files under `crates/promptforge-api/tests/prompts/{valid,invalid,execution}/`; benches use criterion under `crates/*/benches/`; test names are long descriptive snake_case sentences (e.g. `a_process_lifetime_lease_recovers_after_its_owner_is_terminated`); UI tests run via `npm test` in `crates/workshop-server/ui` and `crates/gateway-config-ui/ui`; the structural and boundary harness is `cargo test -p build-xtask` +- Directory map: `crates/` holds every Rust crate (workspace members are `crates/*`; `shared-ui` is excluded as TypeScript-only), named by family prefix; `guide/` is the mdbook user guide (`mdbook build guide`); `prompts/` holds shipped prompt Markdown; `tools/` holds Node `.mjs` repo scripts with `.test.mjs` tests beside them; `vibe/` holds design docs, dated plans, and `archdoc.md`; `images/` holds README assets; `local/` holds local configuration; `.github/workflows/` holds CI; `.githooks/`, `.cargo/`, `.config/` hold repo configuration; `target/` and `target-msrv/` are build outputs +- Component boundaries: three products - PromptForge runtime (`promptforge-*`), Gateway (`gateway-*`), Workshop (`workshop-*`) - plus shared crates (`shared-*`, which depend on no product crates) and build crates (`build-*`); workshop crates must not depend on gateway crates; gateway crates must not depend on promptforge or workshop crates; promptforge crates 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 internal promptforge substrate crates; the executor depends on the gateway, store, Lua VM boundary, and shared substrate; the gateway owns model routing, provider access, and local inference lifecycle; CLI and Workshop are hosts embedding the executor +- Conventions summary: Rust edition 2024, resolver 3, workspace version 0.3.0, license BSL-1.0; workspace lints forbid unsafe code and deny clippy `all`, `unwrap_used`, and `expect_used`; no file exceeds 500 lines; every workshop crate's lib.rs opens with a `## Invariants` doc listing what it may and may not depend on; dependencies flow shell -> features -> services -> vocabulary; behavior changes ship with tests in the same change; comments explain non-obvious constraints and cite upstream issue URLs for workarounds; SPA CSS lives beside its TypeScript and uses `--ws-*` design tokens + +### Terminology + +Terminology for readers new to the codebase: + +- **H1 / H2**: a prompt is one Markdown document. The H1 (title) section holds live Lua that runs first - today it binds tools and models; under this plan binding moves to the frontmatter and H1 keeps `argv` repair and local tool creation. Each H2 heading opens a section with its own fresh Lua VM. "The walk" is the executor's traversal of those sections. +- **The picker** (`promptforge-tool-picker`): a local sentence-embedding model that maps English prose ("read a file from disk") to a tool. Until this plan it was the ONLY binding path; under this plan it becomes the journaled fill function behind fuzzy tool slots, and later powers the discovery capability when that lands. +- **Slots / bindings**: the pattern this plan applies to models and tools alike. The parsed prompt exposes slots (what the prompt wants: model roles, tool slots); a fill function at prepare maps each slot to a concrete thing; the journaled result is the bindings (`ModelBindings`, `ToolBindings`). +- **The offering / open posture** (deferred 2026-09-13): host-push tool arming. A prompt declaring the open posture (`tools: { open: true }`) accepts whatever capabilities the host arms the run with; the offering is host policy carried per-run, and the prompt adapts via reflection (`prompt.tools`, `open_tools`). +- **The VFS** (`shared-vfs`): one virtual filesystem per run. The store (mounted at `/_promptforge/store`) is the run's scratchpad - how sections and fanout arms pass data. Every file operation carries a claim (read or write intent keyed by canonical path); two live identities conflicting on one path is a fatal determinism violation, which is what makes runs replayable. +- **`models.loop`**: the Rust-backed model-and-tool loop Lua calls to run a turn: it sends messages, dispatches the model's tool calls, appends results, repeats until the model finishes. +- **The interface**: the executor's public entry point, `run(...)`, plus the context objects beside it. +- **A host**: whatever embeds the executor - Workshop today; a CLI or automation tomorrow. +- **Fanout**: a prompt primitive that runs several arms concurrently and joins their results; arms coordinate through the store. +- **Bashkit vs terminal**: bashkit is an in-process sandboxed shell whose 142 commands all read through the VFS; a terminal capability would run real host processes, which see the host disk, not the VFS. A context gets one or the other, never both - a context with two filesystem realities ("split-brain") writes files one reality can't see. Real terminal work happens in a dedicated sub-prompt that binds only the terminal. +- **Guard-wrap / trust**: tool output carries a trust flag; untrusted content (web pages, AGENTS.md files, sub-prompt output) is wrapped in a nonce-marked envelope before the model sees it, so fetched content can't forge its way out of its data block. +- **The Stalker**: the operator's research/report prompt - it does heavy web research and is the motivating example for optional capabilities (it should run with or without the user's MCP-connected private sources). +- **Freeform prompt / freeform prompt tool**: there are none (user, 2026-09-13: "there must be no freeform prompts"). A prompt with no `args:` declaration gets the DEFAULT declaration: one optional string field named `prose` (description: "Freeform input for this prompt"). Optional means a tool call may omit the field entirely, and absent is not the empty string. Every prompt's advertised tool schema is its real declaration - nothing is synthetic and the adapter strips nothing. Prose at the interface wraps into the default shape, so the input is `argv.prose` on every channel. + +### Current-State Facts + +Current-state facts established by two codebase maps and a targeted VFS review (2026-09-12/13): + +- Frontmatter is `deny_unknown_fields` with keys `name`, `description`, `promptforge`, `max_tool_iterations`, `input`, `output` (`crates/promptforge-parser/src/build.rs` ~55-76); parse errors use `Error::ParseFrontmatter`; frontmatter YAML failures currently carry no span in `ParseError`, though the `serde_yaml_ng` error is retained as `#[source]` with its location intact (see the 2026-09-13 parser verification below). +- `ToolId` is 2-part `(server, name)` with `/` forbidden (`crates/shared-promptforge-api/src/tools/ids.rs` ~145-167); the picker has its own 2-part `ToolId` (`crates/promptforge-tool-picker/src/catalog.rs`). +- `tools.bind(alias, prose)` resolves through the picker (`crates/promptforge-lua/src/live.rs` ~144-273; `crates/promptforge-api/src/resolve.rs`); `tools.add`/`add_local` are H2-only; the alias grammar is `[A-Za-z][A-Za-z0-9_-]{0,63}` (`live.rs` ~324); advertising already uses the alias as the schema name (`crates/promptforge-api/src/execute/scope.rs` ~94). +- The interface today is `run(prompt, args, resolution: ResolutionContext, config: RunConfig) -> Result` (`crates/promptforge-api/src/execute.rs` ~180-185); `ResolutionContext` is borrowed and carries picker/models/tools (`execute/gateway.rs` ~17-26); `RunConfig` is an owned builder carrying execution, observer, debug, client, cancel, limits, input, ui, on_delta, vfs (`execute/config.rs` ~187-198). +- `RunError` is a `#[non_exhaustive]` newtype over the internal `Error` with `kind()`, `is_cancelled`, `is_retryable`, and `source` (`execute/error.rs` ~54-58); `RunErrorKind` has Parse, Version, Binding, Completion, Tool, Store, Determinism, Lua, Quota, ContextExhausted, Input, Substitution, Cancelled, Internal. +- Workshop ships an empty `ToolCatalog` and no picker (`crates/workshop-sessions/src/agents/supervisor/effects.rs` ~141-152); the only production tools are `promptforge/web_fetch` (`crates/promptforge-webfetch`) and `promptforge/web_search` (`crates/promptforge-web-search`); the user-input tool lives host-side in `workshop-sessions`. +- The VFS (`crates/shared-vfs`): `VfsRef::builder()` builds a router with a fresh claims table; `overlay()` shares the claims table; a mounted handle applies its own claims under the caller's identity; longest-prefix routing with lazy per-mount acquire; the op sink fires once with the caller's origin; `Access` Drop releases claims. `promptforge-vfs` carries `STORE_MOUNT` (`/_promptforge/store`), `empty()`, and `ModePolicy` (Ask/Plan/Agent) (`crates/promptforge-vfs/src/lib.rs`). +- `ModelCatalog` and `ModelDescriptor` live in `crates/shared-promptforge-api/src/models.rs` ~250-259. 2026-09-13 verification: `ModelDescriptor` fields are exactly `id`, `description`, `context`, `thinking` - no modalities field. `ModelCatalog` has NO default-model concept (`new`/`empty`/`get`/`contains` only). Model handles already expose `name`, `model_id`, `description`, `context`, `thinking`, `temperature`, `max_tokens` (`promptforge-lua/src/models/userdata.rs` ~105-114); identity is `.model_id`, not `.id`. +- 2026-09-13 verification, interface migration cost: 26 literal `run` invocations across 11 files (1 production in workshop-sessions, the rest tests/benches/doc examples; 3 test wrappers absorb most suites). +- 2026-09-13 verification, H1 surface: `models.loop` is section-only today (`coro.rs` ~120-137 installs it on section VMs only); the live H1 tools table installs `bind`/`always`/`add` only - `add_local` is absent in H1 (a nil-call), not stubbed. +- 2026-09-13 verification, parser: `Prompt::parse(input, execution, observer)` - the second argument is an observation execution id, NOT the prompt name; the name arrives via frontmatter deserialize. The `serde_yaml_ng` error is retained as `#[source]` with its `location()` intact (never copied into `ParseError.span()`). +- 2026-09-13 verification, alias grammar: the `[A-Za-z][A-Za-z0-9_-]{0,63}` rule is DUPLICATED in `promptforge-lua/src/live.rs` ~324 and `promptforge-lua/src/models/decode.rs` ~208 - consolidate to one helper when touching them (same approach as the earlier VFS glob-rule consolidation: move one copy into the shared helper, delete the other). +- 2026-09-13 verification, prompt-pack feasibility: nested `execute::run` from `Tool::call` does not violate the single-driver model and does not deadlock (nested run = fresh Scheduler); cancel is task-local so sub-runs need `CancelHandle::child()`; `MAX_CALL_DEPTH` (8) counts per-run section chains only, so the pack depth counter is required; untrusted tool output is nonce-wrapped at dispatch (`promptforge-lua/src/dispatch.rs` ~71-73). +- 2026-09-13 verification, migration scale: 4 shipped prompts use prose binds (1 tools.bind, 4 models.bind/default), `chat.md` needs no bind migration, 3 fenced guide examples, and ~60-80 prose bind call sites in tests concentrated in `promptforge-lua/src/tests.rs` and promptforge-api execute/model test modules. +- 2026-09-13 verification, external consumer: papergate (`wg21-paperflow/crates/papergate`) is the first out-of-repo host - a CLI consuming the `promptforge_core` facade via `execute::run(&parsed, args, resolution, &store, config)` (five arguments; an explicit store rides alongside ResolutionContext/RunConfig, a slightly different shape than the interface recorded above). It builds a picker over an empty catalog as pure ceremony and manages its own temp-dir store - both eliminated by the new interface. Its migration is the external validation of the prepare -> adjust -> run flow. +- The one-interface plan (`vibe/2026-09-12-5-one-door-promptforge-api.md`) explicitly deferred "host-installed tool groups / global tool namespace" - this plan is that work. +- Background research (permanent locations): the naming survey `promptforge-design/research/naming-survey-tool-packs.md` (2026-09-13; four sub-surveys behind it) and the Everruns feature survey plus integration-path analysis (`promptforge-design/research/feature-survey-everruns.md` and `promptforge-design/research/integration-path-everruns-capabilities.md`, 2026-09-12). + + + + +## Execution Instructions + +Components in dependency order (each is independently useful and shippable; every component's steps are contiguous): + +1. **global-names** - the arity grammar every other component speaks; nothing can name capabilities or tools without it. Independent of interface and frontmatter. +2. **interface** - the Environment/RunContext/RunResult consolidation; a pure refactor and the parity gate, so it lands before any new behavior rides the interface. Independent of global-names and frontmatter; sequenced early so later steps build on the final shapes. +3. **frontmatter** - the static contract (`capabilities`, `tools`, `args`, `models` keys); independent of the first two, placed here because prepare consumes its output. +4. **capabilities** - the Capability trait, registry, and prepare; depends on global-names (`CapabilityId`), interface (`Environment`), and frontmatter (the declaration prepare resolves). +5. **binding** - assembly, slot filling, and the Lua surface; depends on capabilities (registry, prepare, catalog) and frontmatter (slots). +6. **first-party** - the `promptforge/web` capability and Workshop wiring; depends on binding (slots, prepare) and interface (the shared Environment). +7. **docs** - guide and AGENTS.md updates; last, once every user-facing surface is final. + +(The prompt-pack component - the directory-of-prompts capability and its sub-run adapter - is deferred; see Deferred: the prompt-pack capability. Its design remains in the Decision Record and its steps were removed from this plan 2026-09-13.) + +Pieces within each component are built sequentially in step order: each step's tests need the previous step's artifacts (the grammar before the ids it re-bases, the registry before the prepare that queries it, the assembled catalog before the slots filled against it). Steps 1-3, 4, and 5-6 are mutually independent and may be built in parallel; everything else is sequential. + + + +### Step 1: GlobalName grammar [completed] + +- Component: global-names + +New `names` module in `shared-promptforge-api`: `GlobalName` (private segments, 2 or 3; kind encoded by arity), `GlobalName::parse`, `namespace()`, `pack()`, Display round trip, and `GlobalNameError` (kinds `SegmentCount | Empty | Control`). Segment charset is lowercase ASCII alphanumeric plus `-`, `_`, `.`; case-sensitive comparison; `@` is a parse error (v1 unversioned; normalization-collision rejection deferred). Tests: the GlobalName parse/validation matrix from the Testing Plan. + + + + + +### Step 2: ToolId re-base and built-in id migration + +- Component: global-names + +`tools::ToolId` becomes a newtype over `GlobalName` requiring exactly 3 segments, with `name()` and `capability()` (the 2-segment prefix as its own id - dropping the last segment always yields the contributing capability). Migrate the built-in ids `promptforge/web_fetch` -> `promptforge/web/fetch` and `promptforge/web_search` -> `promptforge/web/search` in `promptforge-webfetch` and `promptforge-web-search`. Sequential after step 1 (it re-bases on the grammar). Tests: ToolId parse, containment, and accessor cases plus migrated-id tests in both tool crates. + + + + + +### Step 3: Picker ToolId migration + +- Component: global-names + +Migrate the picker's own 2-part `ToolId` (`promptforge-tool-picker/src/catalog.rs`) onto the `GlobalName` grammar so lint and discovery speak the same names. Sequential after step 2 (one id type, one grammar). Tests: the picker crate's existing suite stays green, plus id-migration cases. + + + + + +### Step 4: Interface consolidation (parity gate) + +- Component: interface + +Pure refactor, no behavior change: merge `ResolutionContext` and `RunConfig` into `Environment`/`RunContext` in `promptforge-api::execute` per the live declarations (Environment: registry slot, client, base_vfs, max_depth; RunContext: name, start_time, depth, observer, cancel, client, input, ui, limits, debug, on_delta, vfs; the RunConfig builder methods renamed on). `run(prompt, args, ctx) -> RunResult` with the `Ok(String) | Cancelled | Failure(RunError)` enum; add the `RunErrorKind::RequirementsUnmet` variant (its behavior lands in later steps); `env.run(&prompt, args, ctx)` convenience. Intermediate state, stated explicitly so this step does not jump to the end state: the consolidated Environment absorbs ResolutionContext's contents (picker, ModelCatalog, tools) as internal fields and prose binding still works; the picker leaves the bind path and the model moves onto the RunContext only in the binding component. Migrate all 26 call sites across 11 files (the workshop-sessions production call, tests, benches, doc examples). Tests: the entire existing suite stays green - this step is the parity gate. + + + + + +### Step 5: Frontmatter contract keys + +- Component: frontmatter + +`promptforge-parser` gains the `capabilities`, `tools`, `args`, `models` keys under the existing `deny_unknown_fields` schema: capability id shape (2 segments; `@` rejected), the optional flag and prompt-side config, tool slots (alias grammar on keys; an exact value parses as a 3-segment ToolId; a fuzzy slot has a `want` string; the reserved `open` key is rejected), args declarations (name/type sanity; an omitted `args:` key yields the default declaration of one optional string field named `prose`), and model roles (label grammar, the closed keyword vocabulary with unknown keywords as parse errors, `min_context`, description). Parse exposes the FULL declaration on the parsed `Prompt` - every model slot and tool slot - regardless of how the host will satisfy it. Tests: the frontmatter matrix from the Testing Plan (valid matrix, unknown key still rejected, bad capability id, `@` rejected, optional flag, args/models/tool-slot round trips, unknown keyword rejected). + + + + + +### Step 6: Structured parse error locations + +- Component: frontmatter + +Surface the retained `serde_yaml_ng` location into parse errors and add `SourceLocation { path, line, column, span }` plus `RunError::location()` in `promptforge-api` per the live declarations (path is the frontmatter name when parse got that far, the host's label otherwise; internal faults carry the Rust file/line). Sequential after step 5 (locations ride the new keys' error paths). Tests: prompt-source positions carry name/line/column; internal faults carry the Rust file/line. + + + + + +### Step 7: Capability trait and activation types + +- Component: capabilities + +New `capabilities` module in `shared-promptforge-api` (the crate gains its `shared-vfs` dependency): `CapabilityId` (2-segment GlobalName), the `Capability` trait (`id`, `description`, `create(&RunServices) -> Result`), `#[non_exhaustive] RunServices { vfs, cancel }`, `#[derive(Default)] Contribution { tools }` (v1 tools-only), and `CapabilityError` (kind plus a model-readable message, mirroring ToolError). Depends on steps 1 and 4 (GlobalName; RunServices sits beside the new interface). Tests: trait object safety, default Contribution, error display. + + + + + +### Step 8: CapabilityRegistry + +- Component: capabilities + +`CapabilityRegistry` in `promptforge-api`: an explicit host-built registry (`new`, `register`, `get`) - linking alone registers nothing, and v1 is one capability per id. `RegistryError` with `DuplicateId`, plus the registration-time near-duplicate lint over capability descriptions via the picker. Sequential after step 7. Tests: duplicate id rejection, exact lookup, the lint fires. + + + + + +### Step 9: prepare, Requirements, and the per-run VFS + +- Component: capabilities + +`Environment::prepare(&self, prompt, ctx) -> (RunContext, Requirements)`: resolve declared capabilities against the registry in declaration order (missing required land in `Requirements.missing_required`; absent optionals are skipped and logged), call `create(&RunServices)` per present capability, and build the per-run VFS as a fresh router mounting `env.base_vfs` at `/` plus a fresh memory backend at the store mount - never an overlay (claims are shared per storage, not per namespace; the Environment's base carries host roots only, no store mount). `Requirements { unmet_requirements, missing_required }` per the live declarations. Depends on steps 5 and 8 (the parsed declaration; the registry). Tests: fixture-capability integration tests (missing required reported, optional skipped and logged, host config reaching `create`) and the claims isolation matrix - two concurrent runs writing the same store path proceed without conflict; two concurrent runs writing the same host file through the shared base hit a determinism violation. + + + + + +### Step 10: ModelBindings and the trivial fill + +- Component: binding + +`ModelBindings` (roles label->ModelId, models ModelId->ModelDescriptor) in `promptforge-api`; prepare's fill function binds every declared role to the RunContext's current model (v1's deliberately trivial fill); hard keywords (`thinking`, `no-thinking`) and `min_context` are checked per slot against the filled descriptor into `Requirements.unmet_requirements` (required vs actual, naming the role); `env.run` refuses an unsatisfiable prompt with `RunResult::Failure` carrying `RequirementsUnmet` and a model-readable notice. Handles resolve label->id->descriptor. Depends on step 9 (prepare). Tests: every declared role resolves to the current model; `min_context: 200000` against a 32k model is reported; `env.run` fails on it with the notice; implicit prepare via `env.run`. + + + + + +### Step 11: Catalog assembly and conflict checks + +- Component: binding + +Prepare assembles contributed tools into the run's `ToolCatalog` in declaration order, enforces tool prefix-containment at assembly (each contributed tool's id sits under its capability's full id), and rejects capability co-activation conflicts (bashkit vs terminal) naming both. Sequential after step 10 within prepare; split from slot filling because the conflict and containment tests need no slots. Tests: a co-activation conflict fails preparation naming both capabilities; a containment violation is rejected at assembly. + + + + + +### Step 12: ToolBindings and slot filling + +- Component: binding + +`ToolBindings` (alias->ToolId, ToolId->Arc) in `promptforge-api`: exact slots fill by identity against the assembled catalog (an exact path's first two segments name its capability, so a slot whose capability is inactive lands in `missing_required`); fuzzy slots fill via the picker; unfillable optional fuzzy slots skip-and-log. The `ModelBindings`, the `ToolBindings` (including fuzzy fills), and every capability activation are journaled at run start. Depends on step 11 (filling is against the assembled catalog). Tests: an exact slot fills; an exact slot whose capability is inactive is reported; a fuzzy slot fills via the picker and the fill is journaled; an optional fuzzy slot with no match is skip-and-logged. + + + + + +### Step 13: Lua surface consolidation + +- Component: binding + +In `promptforge-lua`: `tools.bind` removed entirely (binding is frontmatter), `models.bind` removed (frontmatter labels auto-bound), `models.default` takes a label, model handles gain `label` and `capabilities` (the full actual keyword set), and `tools.add`/`add_local` work in H1 (install a loop shim and `add_local` in H1 - both absent today, per the 2026-09-13 verification). One `SectionVm::for_section(shared, section_index)` install path with H1 as section 0 and the `argv` writability gate as the only special case; the H1 control stubs and the live H1 binding machinery delete. Only filled slots are visible to `tools.add`/`always` and `tools.call`; advertising an unfilled alias is an error. A failed H1 assertion ends the run before the walk as `RunResult::Failure` with `RunErrorKind::RequirementsUnmet` and the failure notice. Consolidate the duplicated alias-grammar helper (`live.rs` and `models/decode.rs`) into one. Depends on steps 10-12 (bindings arrive pre-filled from prepare). Tests: the bind removals, label-based `models.use`/`default`, handle inspection, the H1 decision-tool idiom, the failed-H1-assertion result, host cancellation as `RunResult::Cancelled`, and the preserved alias/scope behavior tests. + + + + + +### Step 14: args/argv surface and substitution + +- Component: binding + +`args` is the exact passed string, always (unchanged); `argv` is the parsed JSON on success, nil otherwise (`if argv then` as the malformed check); `argv` is writable in H1 only and frozen when H1 completes (an H2 assignment is an error). A default-declared prompt wraps interface prose into `argv = { prose = "" }` (the tool channel that shares this spelling defers with the prompt-pack); structured declarations never wrap; absent is not the empty string. `{{ args }}` renders the raw string unchanged; `{{ argv }}` and `{{ argv.query }}` join the substitution namespaces with dotted-into-scalar a catchable error. Depends on step 13 (the H1 freeze gate is installed there). Tests: the full args-surface matrix from the Testing Plan (exact-string args, parsed-or-nil argv, strict and repair H1 paths, downstream visibility of H1 repair, H2 assignment error, absent vs empty string, substitution cases). + + + + + +### Step 15: Prose binding migration + +- Component: binding + +Migrate the 4 shipped prompts, the executor fixtures, the 3 fenced guide examples, and the ~60-80 prose bind call sites in tests (concentrated in `promptforge-lua/src/tests.rs` and the promptforge-api execute/model test modules) to frontmatter tool slots and model labels; existing alias/scope behavior tests are preserved. Sequential last in the component - it consumes every prior binding surface. Tests: the migrated suites stay green. + + + + + +### Step 16: The promptforge/web capability + +- Component: first-party + +Combine `promptforge-webfetch` and `promptforge-web-search` into the single `promptforge/web` capability contributing `promptforge/web/search` and `promptforge/web/fetch` (a research prompt wants both or neither; one capability, one frontmatter line). Depends on steps 2, 7, and 8 (the migrated ids, the trait, the registry). Tests: activating the capability contributes both tools under its full id. + + + + + +### Step 17: Workshop wiring + +- Component: first-party + +`workshop-sessions` builds one shared model-free `Environment` at startup and creates a per-session RunContext carrying the dropdown's current model (a selection change takes effect on the next run); the gateway's model list feeds the dropdown UI only, via `fetch_model_catalog`, and never crosses the executor interface. `chat.md` gains `capabilities: [promptforge/web]`, `tools:` slots for both tools, and `tools.always` advertising. Depends on steps 4, 9-12, and 16. Tests: a Workshop session activates the capability, fills its tool slots, and calls one end to end; `chat.md` runs on its declared frontmatter. + + + + + +### Step 18: Guide and AGENTS.md + +- Component: docs + +Land every user-facing change in the guide, mapped to its chapter: `guide/src/language/01-frontmatter-and-structure.md` (the four contract keys), `04-lua-globals-and-store.md` (args/argv, the H1 repair pattern, absent vs empty string), `05-prose-substitution.md` (`{{ args }}` unchanged, `{{ argv }}` and `{{ argv.query }}` new), `06-models.md` (roles, labels, the closed keyword vocabulary, inspectable handles, the v1 trivial fill), `07-tools.md` (capabilities as the installation unit, global tool paths, slots bound at prepare, advertising vs binding, the decision-tool recipe, the picker's new role), and `02-the-run.md` (the prepare -> Requirements -> run flow and the RunResult shape). Migration notes with before/after examples wherever `tools.bind`/`models.bind` were documented; one short "designed, not yet built" note each for the deferred open posture, the `prompt` reflection global, and the prompt-pack capability. The root `AGENTS.md` gains a Principles rule: error and status messages are designed assuming model consumption. Last, once every user-facing surface is final. Tests: `mdbook build guide` and the doc build stay green. + + + +- Verification: every step is one commit containing its code and tests; step 4 is the parity gate (no behavior change, full suite green); the claims isolation matrix in step 9 is the determinism gate. Data flow: the parser (steps 5-6) produces the full declaration consumed by prepare (step 9), which needs the registry (step 8) and the consolidated interface (step 4); the run consumes the bindings (steps 10-12) produced by prepare; Workshop supplies the registry and the per-run model and migrates the built-ins the naming work renamed (steps 16-17). Exit criteria: all acceptance criteria in the Functional Specification pass; the full workspace suite, clippy, fmt, and doc builds are green. + + \ No newline at end of file diff --git a/vibe/ACTIVE b/vibe/ACTIVE new file mode 100644 index 00000000..ef6180bc --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +vibe/2026-09-13-1-capabilities-global-naming.md \ No newline at end of file From ea7ed4568d9bc70c58be15b26ce237bcb096ce97 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 18:56:07 -0700 Subject: [PATCH 02/30] Re-base ToolId on GlobalName and migrate built-in ids Tool identity is re-based on the global naming grammar: a tool id is now a three-segment global name whose first two segments name the capability that contributed it, so dropping the last segment always yields the contributing capability's id. Parsing a single string replaces component-wise construction, and the two built-in tools migrate onto the grammar. The picker still speaks two-part ids, so the resolver packs the capability prefix into the picker's server part for a lossless round trip until the picker's own migration lands. - `ToolId` becomes a newtype over `GlobalName`: `ToolId::parse` requires exactly 3 segments, and `capability()` returns the 2-segment prefix as a `GlobalName`, to be re-typed to `CapabilityId` when the capabilities module lands. - `GlobalName` gains crate-internal `from_validated`, `segments`, and `capability_prefix` backing the id newtypes in `crate::tools`. - `ToolIdErrorKind::SegmentCount` rejects ids whose segment count is not exactly 3; `from_global_name_kind` maps global-name rejections onto the tool-id error vocabulary, and `Separator` now covers wire names only. - `ToolId::from_validated` builds static first-party ids without revalidating under a debug assertion on segment count; the built-ins migrate to `promptforge/web/fetch` and `promptforge/web/search`. - `tool_id_of` and the near-duplicate mapping carry a core id through the picker as server = capability prefix and name = tool name, a lossless round trip while the picker still speaks 2-part ids. - `the_migrated_id_names_its_contributing_capability` pins in both tool crates that dropping the last segment yields `promptforge/web`. - `ToolId::new` and `server()` are gone: construction is `ToolId::parse` or the hidden `ToolId::from_validated`, and the capability prefix replaces the server accessor. Design: encapsulated-invariant -> newtype @ crates/shared-promptforge-api/src/tools/ids.rs::ToolId boundary: pub Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- .../src/execute/tests/live_infer.rs | 2 +- .../promptforge-api/src/execute/tests/mod.rs | 16 +- .../src/execute/tests/observations.rs | 2 +- .../src/execute/tests/scheduler.rs | 2 +- .../src/execute/tests/tool_scoping.rs | 4 +- crates/promptforge-api/src/lua/coro_tests.rs | 2 +- crates/promptforge-api/src/resolve.rs | 56 +++---- crates/promptforge-api/src/tools/tests.rs | 8 +- crates/promptforge-lua/src/dispatch.rs | 6 +- crates/promptforge-lua/src/protocol.rs | 2 +- crates/promptforge-lua/src/tests.rs | 26 ++-- crates/promptforge-lua/src/tools/tests.rs | 4 +- .../promptforge-web-search/src/web_search.rs | 2 +- .../src/web_search/tests.rs | 12 +- crates/promptforge-webfetch/src/tool.rs | 18 ++- crates/shared-promptforge-api/src/names.rs | 27 ++++ .../shared-promptforge-api/src/tools/ids.rs | 142 +++++++++++------- .../src/tools/registry.rs | 6 +- .../shared-promptforge-api/src/tools/tests.rs | 115 ++++++++++---- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 20 files changed, 295 insertions(+), 159 deletions(-) diff --git a/crates/promptforge-api/src/execute/tests/live_infer.rs b/crates/promptforge-api/src/execute/tests/live_infer.rs index f7755b06..06bf9f2e 100644 --- a/crates/promptforge-api/src/execute/tests/live_infer.rs +++ b/crates/promptforge-api/src/execute/tests/live_infer.rs @@ -148,7 +148,7 @@ async fn shared_library_calls_host_apis_at_load_time() { async fn captured_bindings_reach_section_call_and_fanout_vms() { let echo = Arc::new(EchoTool); let descriptor = ToolDescriptor::new( - PickerToolId::new("tests", "echo"), + PickerToolId::new("tests/tools", "echo"), echo.description(), echo.parameters_schema(), ); diff --git a/crates/promptforge-api/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs index 5443c92f..c46c75b7 100644 --- a/crates/promptforge-api/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -333,7 +333,7 @@ async fn run( .map(|tool| { let id = tool.id(); ToolDescriptor::new( - PickerToolId::new(id.server(), id.name()), + PickerToolId::new(id.capability().to_string(), id.name()), tool.description(), tool.parameters_schema(), ) @@ -482,7 +482,7 @@ struct EchoTool; #[async_trait::async_trait] impl Tool for EchoTool { fn id(&self) -> ToolId { - ToolId::new("tests", "echo").expect("valid id") + ToolId::parse("tests/tools/echo").expect("valid id") } #[expect( @@ -535,7 +535,7 @@ struct UntrustedEchoTool; #[async_trait::async_trait] impl Tool for UntrustedEchoTool { fn id(&self) -> ToolId { - ToolId::new("tests", "untrusted_echo").expect("valid id") + ToolId::parse("tests/tools/untrusted_echo").expect("valid id") } #[expect( @@ -584,7 +584,7 @@ struct StructuredFixtureTool { #[async_trait::async_trait] impl Tool for StructuredFixtureTool { fn id(&self) -> ToolId { - ToolId::new("tests", "structured").expect("valid id") + ToolId::parse("tests/tools/structured").expect("valid id") } #[expect( @@ -623,7 +623,7 @@ struct FailingTool; #[async_trait::async_trait] impl Tool for FailingTool { fn id(&self) -> ToolId { - ToolId::new("tests", "failing").expect("valid id") + ToolId::parse("tests/tools/failing").expect("valid id") } #[expect( @@ -667,7 +667,7 @@ struct ScopedFixtureTool { impl ScopedFixtureTool { fn new(name: &str, wire_name: &'static str, description: &'static str) -> Self { Self { - id: ToolId::new("tests", name).expect("valid id"), + id: ToolId::parse(&format!("tests/tools/{name}")).expect("valid id"), wire_name, description, calls: Arc::new(AtomicUsize::new(0)), @@ -1207,7 +1207,7 @@ fn bind_override_reaches_the_schema_and_add_beats_bind() { vec![crate::lua::ToolBinding { alias: "echo".to_owned(), description: "echo capability for live matching".to_owned(), - id: ToolId::new("tests", "echo").expect("valid id"), + id: ToolId::parse("tests/tools/echo").expect("valid id"), model_description: Some("bind override".to_owned()), tool: Arc::new(EchoTool), conflicts: Vec::new(), @@ -1319,7 +1319,7 @@ struct SlowTool; #[async_trait::async_trait] impl Tool for SlowTool { fn id(&self) -> ToolId { - ToolId::new("test", "slow").expect("valid slow tool id") + ToolId::parse("test/tools/slow").expect("valid slow tool id") } #[expect( diff --git a/crates/promptforge-api/src/execute/tests/observations.rs b/crates/promptforge-api/src/execute/tests/observations.rs index b6220d5e..71842de2 100644 --- a/crates/promptforge-api/src/execute/tests/observations.rs +++ b/crates/promptforge-api/src/execute/tests/observations.rs @@ -284,7 +284,7 @@ async fn one_execution_id_spans_parse_and_the_complete_runtime_lifecycle() { "Echo a test value.", )); let descriptor = ToolDescriptor::new( - PickerToolId::new("tests", "echo"), + PickerToolId::new("tests/tools", "echo"), tool.description(), tool.parameters_schema(), ); diff --git a/crates/promptforge-api/src/execute/tests/scheduler.rs b/crates/promptforge-api/src/execute/tests/scheduler.rs index 482542c5..2c0a78da 100644 --- a/crates/promptforge-api/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api/src/execute/tests/scheduler.rs @@ -3299,7 +3299,7 @@ struct SignallingSlowTool { #[async_trait::async_trait] impl Tool for SignallingSlowTool { fn id(&self) -> ToolId { - ToolId::new("tests", "slow").expect("valid id") + ToolId::parse("tests/tools/slow").expect("valid id") } #[expect( diff --git a/crates/promptforge-api/src/execute/tests/tool_scoping.rs b/crates/promptforge-api/src/execute/tests/tool_scoping.rs index 696a8993..241818b9 100644 --- a/crates/promptforge-api/src/execute/tests/tool_scoping.rs +++ b/crates/promptforge-api/src/execute/tests/tool_scoping.rs @@ -300,9 +300,9 @@ fn near_duplicate_effective_scope_fails_before_the_model_without_payload_reports Error::NearDuplicateTools { diagnostic, } if diagnostic.first_alias == "first_local" - && diagnostic.first_id == ToolId::new("tests", "first").expect("valid id") + && diagnostic.first_id == ToolId::parse("tests/tools/first").expect("valid id") && diagnostic.second_alias == "second_local" - && diagnostic.second_id == ToolId::new("tests", "second").expect("valid id") + && diagnostic.second_id == ToolId::parse("tests/tools/second").expect("valid id") && (diagnostic.similarity - 0.98).abs() < f64::EPSILON )); let events = recorder.events(); diff --git a/crates/promptforge-api/src/lua/coro_tests.rs b/crates/promptforge-api/src/lua/coro_tests.rs index 31ac8013..0e4a0140 100644 --- a/crates/promptforge-api/src/lua/coro_tests.rs +++ b/crates/promptforge-api/src/lua/coro_tests.rs @@ -48,7 +48,7 @@ struct StubTool; #[async_trait::async_trait] impl Tool for StubTool { fn id(&self) -> ToolId { - ToolId::new("tests", "echo").expect("valid id") + ToolId::parse("tests/tools/echo").expect("valid id") } #[expect( diff --git a/crates/promptforge-api/src/resolve.rs b/crates/promptforge-api/src/resolve.rs index 39b1710d..f8b8ae0d 100644 --- a/crates/promptforge-api/src/resolve.rs +++ b/crates/promptforge-api/src/resolve.rs @@ -145,8 +145,14 @@ enum CachedDecision { } /// Converts a borrowed picker descriptor to a core-owned [`ToolId`]. +/// +/// The picker still speaks 2-part ids (its own migration onto the global +/// grammar is a later step): a core id rides through the picker as server = +/// the capability prefix (`namespace/pack`, which the picker's server part +/// accepts verbatim, separators included) and name = the tool name, so the +/// round trip is lossless. fn tool_id_of(tool: &ToolDescriptor) -> ToolId { - ToolId::from_validated(tool.id().server(), tool.id().name()) + ToolId::from_validated(&format!("{}/{}", tool.id().server(), tool.id().name())) } impl CachedDecision { @@ -322,7 +328,7 @@ where ) -> std::result::Result, promptforge_lua::Error> { let picker_ids = ids .iter() - .map(|id| PickerToolId::new(id.server(), id.name())) + .map(|id| PickerToolId::new(id.capability().to_string(), id.name())) .collect::>(); self.source .near_duplicates(&picker_ids) @@ -331,8 +337,12 @@ where .into_iter() .map(|(first, second, similarity)| { ( - ToolId::from_validated(first.server(), first.name()), - ToolId::from_validated(second.server(), second.name()), + ToolId::from_validated(&format!("{}/{}", first.server(), first.name())), + ToolId::from_validated(&format!( + "{}/{}", + second.server(), + second.name() + )), similarity, ) }) @@ -357,7 +367,7 @@ mod tests { use crate::tools::{Tool, ToolError, ToolOutput}; fn tid(name: &str) -> ToolId { - ToolId::from_validated("tests", name) + ToolId::from_validated(&format!("tests/tools/{name}")) } struct FixtureSource; @@ -497,10 +507,7 @@ mod tests { duplicate, Error::Duplicate { capability, candidates } if capability == "duplicate" - && candidates == [ - ToolId::new("tests", "first").expect("valid id"), - ToolId::new("tests", "second").expect("valid id") - ] + && candidates == [tid("first"), tid("second")] )); assert!(matches!( CachedDecision::Absent.result("absent").map_err(Error::from), @@ -559,15 +566,13 @@ mod tests { "tools.bind('missing', 'first')" ), Error::PickedToolNotLive { alias, id } - if alias == "missing" && id == ToolId::new("tests", "first").expect("valid id") + if alias == "missing" && id == tid("first") )); } #[test] fn live_callbacks_reject_duplicate_aliases_and_identities() { - let tools: Vec> = vec![Arc::new(FixtureTool { - id: ToolId::new("tests", "first").expect("valid id"), - })]; + let tools: Vec> = vec![Arc::new(FixtureTool { id: tid("first") })]; assert!(matches!( callback_error( &FixtureSource, @@ -583,7 +588,7 @@ mod tests { "tools.bind('one', 'same-one'); tools.bind('two', 'same-two')" ), Error::ToolIdSelectedTwice { id, first_alias, second_alias } - if id == ToolId::new("tests", "first").expect("valid id") + if id == tid("first") && first_alias == "one" && second_alias == "two" )); @@ -592,12 +597,8 @@ mod tests { #[test] fn bind_records_near_duplicate_conflicts_symmetrically() { let tools: Vec> = vec![ - Arc::new(FixtureTool { - id: ToolId::new("tests", "first").expect("valid id"), - }), - Arc::new(FixtureTool { - id: ToolId::new("tests", "second").expect("valid id"), - }), + Arc::new(FixtureTool { id: tid("first") }), + Arc::new(FixtureTool { id: tid("second") }), ]; let resolver = PickerResolver::new(&FixtureSource); let catalog = ToolCatalog::new(&tools).expect("fixture tools are unique"); @@ -635,19 +636,12 @@ mod tests { #[test] fn catalog_rejects_duplicate_live_ids() { let tools: Vec> = vec![ - Arc::new(FixtureTool { - id: ToolId::new("tests", "same").expect("valid id"), - }), - Arc::new(FixtureTool { - id: ToolId::new("tests", "same").expect("valid id"), - }), + Arc::new(FixtureTool { id: tid("same") }), + Arc::new(FixtureTool { id: tid("same") }), ]; let error = ToolCatalog::new(&tools) .expect_err("a repeated live identity must be rejected at catalog construction"); - assert_eq!( - error.duplicate_id(), - Some(&ToolId::new("tests", "same").expect("valid id")) - ); + assert_eq!(error.duplicate_id(), Some(&tid("same"))); } #[test] @@ -718,7 +712,7 @@ mod tests { let first_a = resolver.resolve("first").expect("first resolves"); let first_b = resolver.resolve("first").expect("first resolves again"); assert_eq!(first_a, first_b); - assert_eq!(first_a, ToolId::new("tests", "first").expect("valid id")); + assert_eq!(first_a, tid("first")); assert_eq!(source.count("first"), 1, "a hit must not re-decide"); // A failing capability is likewise cached: decided once, stable error. diff --git a/crates/promptforge-api/src/tools/tests.rs b/crates/promptforge-api/src/tools/tests.rs index b3b46991..58c88cfd 100644 --- a/crates/promptforge-api/src/tools/tests.rs +++ b/crates/promptforge-api/src/tools/tests.rs @@ -20,7 +20,7 @@ struct ReexportFixture; #[async_trait::async_trait] impl ContractTool for ReexportFixture { fn id(&self) -> ToolId { - ToolId::new("fixtures", "reexport").expect("fixture id is valid") + ToolId::parse("fixtures/tools/reexport").expect("fixture id is valid") } #[expect( @@ -53,14 +53,14 @@ fn reexported_identity_looks_up_in_reexported_catalog() { let tool: Arc = Arc::new(ReexportFixture); let catalog = ToolCatalog::new(std::slice::from_ref(&tool)).expect("unique catalog"); - let id = crate::tools::ToolId::new("fixtures", "reexport").expect("valid id"); + let id = crate::tools::ToolId::parse("fixtures/tools/reexport").expect("valid id"); let found = catalog .get(&id) .expect("the stable identity should resolve"); assert_eq!(found.wire_name(), "reexport_wire"); assert!( catalog - .get(&crate::tools::ToolId::new("fixtures", "reexport_wire").expect("valid id")) + .get(&crate::tools::ToolId::parse("fixtures/tools/reexport_wire").expect("valid id")) .is_none(), "the transport name must not become identity through the re-export either" ); @@ -78,7 +78,7 @@ fn reexported_types_are_the_contract_types() { catalog.tools().len() } - let id = crate::tools::ToolId::new("fixtures", "reexport").expect("valid id"); + let id = crate::tools::ToolId::parse("fixtures/tools/reexport").expect("valid id"); assert_eq!(takes_contract_id(&id), "reexport"); let tool: Arc = Arc::new(ReexportFixture); diff --git a/crates/promptforge-lua/src/dispatch.rs b/crates/promptforge-lua/src/dispatch.rs index 2e577e73..e22a4357 100644 --- a/crates/promptforge-lua/src/dispatch.rs +++ b/crates/promptforge-lua/src/dispatch.rs @@ -169,7 +169,7 @@ mod tests { #[async_trait::async_trait] impl Tool for EchoTool { fn id(&self) -> ToolId { - ToolId::new("tests", "echo").expect("valid id") + ToolId::parse("tests/tools/echo").expect("valid id") } #[expect( @@ -211,7 +211,7 @@ mod tests { #[async_trait::async_trait] impl Tool for FailingTool { fn id(&self) -> ToolId { - ToolId::new("tests", "failing").expect("valid id") + ToolId::parse("tests/tools/failing").expect("valid id") } #[expect( @@ -252,7 +252,7 @@ mod tests { #[async_trait::async_trait] impl Tool for SlowTool { fn id(&self) -> ToolId { - ToolId::new("tests", "slow").expect("valid id") + ToolId::parse("tests/tools/slow").expect("valid id") } #[expect( diff --git a/crates/promptforge-lua/src/protocol.rs b/crates/promptforge-lua/src/protocol.rs index d2908b40..cdcdfbe8 100644 --- a/crates/promptforge-lua/src/protocol.rs +++ b/crates/promptforge-lua/src/protocol.rs @@ -1647,7 +1647,7 @@ mod tests { let handle = crate::LuaToolHandle::from_binding( "echo", "echo tool", - &shared_promptforge_api::tools::ToolId::new("tests", "echo").expect("valid id"), + &shared_promptforge_api::tools::ToolId::parse("tests/tools/echo").expect("valid id"), ); let userdata = lua.create_userdata(handle).expect("userdata"); table.raw_set("alias", userdata).expect("raw_set"); diff --git a/crates/promptforge-lua/src/tests.rs b/crates/promptforge-lua/src/tests.rs index fb0941bd..37451bc0 100644 --- a/crates/promptforge-lua/src/tests.rs +++ b/crates/promptforge-lua/src/tests.rs @@ -235,7 +235,7 @@ struct FixtureTool(&'static str); #[async_trait::async_trait] impl Tool for FixtureTool { fn id(&self) -> ToolId { - ToolId::new("fixtures", self.0).expect("valid id") + ToolId::parse(&format!("fixtures/tools/{}", self.0)).expect("valid id") } fn wire_name(&self) -> &'static str { @@ -330,14 +330,14 @@ fn section_vm_with_shared( fn fixture_bindings(source: &str) -> ToolSet { let shared = program(source); let resolver = |description: &str| { - Ok(ToolId::new( - "fixtures", + Ok(ToolId::parse(&format!( + "fixtures/tools/{}", if description == "search the web" { "search" } else { "fetch" }, - ) + )) .expect("valid id")) }; execute_live_tool_binds( @@ -363,7 +363,7 @@ fn direct_output_is_absent_in_every_executable_lua_vm() { assert(warn == nil)\n\ tools.bind('search', 'search the web')", ); - let resolver = |_: &str| Ok(ToolId::new("fixtures", "search").expect("valid id")); + let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); let bindings = execute_live_tool_binds( &shared, &resolver, @@ -856,7 +856,7 @@ fn tool_bind_returns_inspectable_object() { assert(tool.untrusted == false)\n\ tools.always('search')", ); - let resolver = |_: &str| Ok(ToolId::new("fixtures", "search").expect("valid id")); + let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); let bindings = execute_live_tool_binds( &shared, &resolver, @@ -874,7 +874,7 @@ fn tool_bind_returns_inspectable_object() { #[test] fn binding_validates_aliases_exactly() { - let resolver = |_: &str| Ok(ToolId::new("fixtures", "search").expect("valid id")); + let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); for alias in [ "", @@ -913,7 +913,7 @@ fn binding_validates_aliases_exactly() { #[test] fn live_h1_rejects_duplicate_aliases() { - let resolver = |_: &str| Ok(ToolId::new("fixtures", "search").expect("valid id")); + let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); let error = execute_live_tool_binds( &program("tools.bind('search', 'one'); tools.bind('search', 'two')"), &resolver, @@ -930,7 +930,7 @@ fn live_h1_rejects_duplicate_aliases() { #[test] fn duplicate_alias_error_cannot_be_suppressed_with_lua_pcall() { - let resolver = |_: &str| Ok(ToolId::new("fixtures", "search").expect("valid id")); + let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); let error = execute_live_tool_binds( &program("tools.bind('search', 'one'); pcall(tools.bind, 'search', 'two')"), &resolver, @@ -947,7 +947,7 @@ fn duplicate_alias_error_cannot_be_suppressed_with_lua_pcall() { #[test] fn binding_rejects_unknown_and_duplicate_always_aliases() { - let resolver = |_: &str| Ok(ToolId::new("fixtures", "search").expect("valid id")); + let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); for (source, expected) in [ ( "tools.always('missing')", @@ -1021,14 +1021,14 @@ fn h2_recording_closes_to_always_then_added_scope() { #[test] fn h2_add_accepts_tool_objects_and_arrays() { let resolver = |description: &str| { - Ok(ToolId::new( - "fixtures", + Ok(ToolId::parse(&format!( + "fixtures/tools/{}", if description == "search the web" { "search" } else { "fetch" }, - ) + )) .expect("valid id")) }; let h1_error = execute_live_tool_binds( diff --git a/crates/promptforge-lua/src/tools/tests.rs b/crates/promptforge-lua/src/tools/tests.rs index af021453..75b0cad9 100644 --- a/crates/promptforge-lua/src/tools/tests.rs +++ b/crates/promptforge-lua/src/tools/tests.rs @@ -25,7 +25,7 @@ fn echo_handle() -> LuaToolHandle { LuaToolHandle::from_binding( "echo", "echo tool", - &ToolId::new("tests", "echo").expect("id"), + &ToolId::parse("tests/tools/echo").expect("id"), ) } @@ -242,7 +242,7 @@ struct EchoTool; #[async_trait::async_trait] impl shared_promptforge_api::tools::Tool for EchoTool { fn id(&self) -> ToolId { - ToolId::new("tests", "echo").expect("valid id") + ToolId::parse("tests/tools/echo").expect("valid id") } #[expect( diff --git a/crates/promptforge-web-search/src/web_search.rs b/crates/promptforge-web-search/src/web_search.rs index 9b4bac9f..b2efecc2 100644 --- a/crates/promptforge-web-search/src/web_search.rs +++ b/crates/promptforge-web-search/src/web_search.rs @@ -357,7 +357,7 @@ async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result ToolId { - ToolId::from_validated("promptforge", "web_search") + ToolId::from_validated("promptforge/web/search") } #[expect( diff --git a/crates/promptforge-web-search/src/web_search/tests.rs b/crates/promptforge-web-search/src/web_search/tests.rs index 615ad48f..6d96de0e 100644 --- a/crates/promptforge-web-search/src/web_search/tests.rs +++ b/crates/promptforge-web-search/src/web_search/tests.rs @@ -88,7 +88,7 @@ fn descriptor_is_stable_and_faithful() { assert_eq!( tool.id(), - ToolId::new("promptforge", "web_search").expect("valid id") + ToolId::parse("promptforge/web/search").expect("valid id") ); assert_eq!(tool.wire_name(), "web_search"); assert_eq!( @@ -151,6 +151,16 @@ fn descriptor_is_stable_and_faithful() { ); } +#[test] +fn the_migrated_id_names_its_contributing_capability() { + // promptforge/web_search migrated to promptforge/web/search: dropping the + // last segment must yield the contributing capability's id. + let tool = WebSearch::new("http://localhost", "test").expect("valid web search configuration"); + let id = tool.id(); + assert_eq!(id.name(), "search"); + assert_eq!(id.capability().to_string(), "promptforge/web"); +} + #[tokio::test] async fn forwards_query_and_returns_untrusted_results() { let mock = MockServer::spawn(success_router()).await; diff --git a/crates/promptforge-webfetch/src/tool.rs b/crates/promptforge-webfetch/src/tool.rs index b5883f93..6c9059da 100644 --- a/crates/promptforge-webfetch/src/tool.rs +++ b/crates/promptforge-webfetch/src/tool.rs @@ -206,13 +206,8 @@ fn map_send_error_to_outcome(err: &reqwest::Error, url: &str) -> CallResult { #[async_trait::async_trait] impl Tool for WebFetch { - #[expect( - clippy::expect_used, - reason = "the id components are compile-time constants that satisfy ToolId's validation" - )] fn id(&self) -> ToolId { - ToolId::new("promptforge", "web_fetch") - .expect("`promptforge`/`web_fetch` is a valid tool id") + ToolId::from_validated("promptforge/web/fetch") } #[expect( @@ -494,7 +489,7 @@ mod tests { assert_eq!( tool.id(), - ToolId::new("promptforge", "web_fetch").expect("valid id") + ToolId::parse("promptforge/web/fetch").expect("valid id") ); assert_eq!(tool.wire_name(), "web_fetch"); assert_eq!( @@ -507,6 +502,15 @@ mod tests { assert_eq!(schema["properties"]["url"]["type"], "string"); } + #[test] + fn the_migrated_id_names_its_contributing_capability() { + // promptforge/web_fetch migrated to promptforge/web/fetch: dropping the + // last segment must yield the contributing capability's id. + let id = WebFetch::new().id(); + assert_eq!(id.name(), "fetch"); + assert_eq!(id.capability().to_string(), "promptforge/web"); + } + #[derive(Clone)] struct AppState { port: u16, diff --git a/crates/shared-promptforge-api/src/names.rs b/crates/shared-promptforge-api/src/names.rs index c04559ad..49491e09 100644 --- a/crates/shared-promptforge-api/src/names.rs +++ b/crates/shared-promptforge-api/src/names.rs @@ -72,6 +72,33 @@ impl GlobalName { pub fn pack(&self) -> &str { &self.segments[1] } + + /// Builds a name from a string already known to satisfy the grammar, + /// skipping validation. + /// + /// Crate-internal: backs [`crate::tools::ToolId::from_validated`] for + /// static first-party ids. + pub(crate) fn from_validated(s: &str) -> GlobalName { + GlobalName { + segments: s.split('/').map(str::to_owned).collect(), + } + } + + /// Returns the segments (exactly 2 or 3 by construction). + /// + /// Crate-internal: the id newtypes in [`crate::tools`] index segments. + pub(crate) fn segments(&self) -> &[String] { + &self.segments + } + + /// Returns the 2-segment capability prefix of a 3-segment (tool) name. + /// + /// Crate-internal: backs [`crate::tools::ToolId::capability`]. + pub(crate) fn capability_prefix(&self) -> GlobalName { + GlobalName { + segments: self.segments[..2].to_vec(), + } + } } impl fmt::Display for GlobalName { diff --git a/crates/shared-promptforge-api/src/tools/ids.rs b/crates/shared-promptforge-api/src/tools/ids.rs index 0f365061..272888e1 100644 --- a/crates/shared-promptforge-api/src/tools/ids.rs +++ b/crates/shared-promptforge-api/src/tools/ids.rs @@ -1,94 +1,108 @@ //! Stable tool identity and its validation errors. +use crate::names::{GlobalName, GlobalNameErrorKind}; + /// The stable identity of a live tool. /// -/// Identity is structural over the server and tool name. The wire name used -/// in a model request is deliberately not identity: later capability binding -/// can advertise a selected tool under a prompt-local alias without changing -/// the live tool it dispatches. +/// Identity is a 3-segment [`GlobalName`] (`namespace/pack/name`): the global +/// naming grammar encodes kind by arity, and a tool's first two segments name +/// the capability that contributed it, so dropping the last segment of any +/// tool id always yields the contributing capability's id +/// (`promptforge/web/fetch` comes from `promptforge/web`, no exceptions). The +/// wire name used in a model request is deliberately not identity: capability +/// binding can advertise a selected tool under a prompt-local alias without +/// changing the live tool it dispatches. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] #[non_exhaustive] -pub struct ToolId { - server: String, - name: String, -} +pub struct ToolId(GlobalName); impl ToolId { - /// Builds an identity from its server and stable tool name. + /// Parses a tool identity, requiring exactly 3 segments + /// (`namespace/pack/name`). /// /// # Errors - /// Returns [`ToolIdError`] if `server` or `name` is empty or contains the - /// `/` namespace separator or a control character. + /// Returns [`ToolIdError`] when the segment count is not exactly 3 + /// ([`ToolIdErrorKind::SegmentCount`]), a segment is empty + /// ([`ToolIdErrorKind::Empty`]), or a segment contains a character outside + /// the global-name charset ([`ToolIdErrorKind::Control`]). /// /// # Examples /// /// ``` /// use shared_promptforge_api::tools::ToolId; /// - /// let id = ToolId::new("promptforge", "web_fetch")?; - /// assert_eq!(id.server(), "promptforge"); - /// assert_eq!(id.name(), "web_fetch"); + /// let id = ToolId::parse("promptforge/web/fetch")?; + /// assert_eq!(id.name(), "fetch"); + /// assert_eq!(id.capability().to_string(), "promptforge/web"); /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` - pub fn new(server: impl Into, name: impl Into) -> Result { - let server = server.into(); - let name = name.into(); - Self::validate("server", &server)?; - Self::validate("name", &name)?; - Ok(Self { server, name }) + pub fn parse(id: &str) -> Result { + let name = + GlobalName::parse(id).map_err(|e| ToolIdError::from_global_name_kind(e.kind()))?; + if name.segments().len() != 3 { + return Err(ToolIdError { + field: "id", + kind: ToolIdErrorKind::SegmentCount, + reason: "a tool id must have exactly 3 segments (namespace/pack/name)", + }); + } + Ok(ToolId(name)) } - /// Builds an identity from components already known to be valid. + /// Builds an identity from a string already known to be valid. /// - /// For internal callers whose inputs are static tool names or come from an - /// existing [`ToolId`], so the validation in [`ToolId::new`] is redundant. - /// Hidden from the public API: downstream callers use [`ToolId::new`]. + /// For internal callers whose inputs are static tool ids, so the + /// validation in [`ToolId::parse`] is redundant. Hidden from the public + /// API: downstream callers use [`ToolId::parse`]. #[doc(hidden)] #[must_use] - pub fn from_validated(server: impl Into, name: impl Into) -> ToolId { - ToolId { - server: server.into(), - name: name.into(), - } + pub fn from_validated(id: &str) -> ToolId { + let name = GlobalName::from_validated(id); + debug_assert!( + name.segments().len() == 3, + "a static tool id must have exactly 3 segments (namespace/pack/name): {id}" + ); + ToolId(name) } - /// Validates one identity component, naming the field in any error. - fn validate(field: &'static str, value: &str) -> Result<(), ToolIdError> { - validate_identifier(field, value) - } - - /// Returns the server that owns this identity namespace. + /// Returns the tool's name segment (the last of the three). /// /// # Examples /// /// ``` /// use shared_promptforge_api::tools::ToolId; /// - /// let id = ToolId::new("promptforge", "web_fetch")?; - /// assert_eq!(id.server(), "promptforge"); + /// let id = ToolId::parse("promptforge/web/fetch")?; + /// assert_eq!(id.name(), "fetch"); /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` #[must_use] - pub fn server(&self) -> &str { - &self.server + pub fn name(&self) -> &str { + &self.0.segments()[2] } - /// Returns the stable name within the publisher's namespace. + /// Returns the contributing capability's id: the first two segments. + /// + /// Containment is total - dropping the last segment of any tool id always + /// yields the id of the capability that contributed it. The return type + /// re-types to the capabilities module's `CapabilityId` when that module + /// lands; the value is already exactly that id. /// /// # Examples /// /// ``` /// use shared_promptforge_api::tools::ToolId; /// - /// let id = ToolId::new("promptforge", "web_fetch")?; - /// assert_eq!(id.name(), "web_fetch"); + /// let id = ToolId::parse("promptforge/web/fetch")?; + /// assert_eq!(id.capability().to_string(), "promptforge/web"); /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` #[must_use] - pub fn name(&self) -> &str { - &self.name + pub fn capability(&self) -> GlobalName { + self.0.capability_prefix() } } + /// A stable, matchable classification of a [`ToolIdError`]. /// /// Every public error exposes a `kind()` classifier so callers can branch on the @@ -96,11 +110,14 @@ impl ToolId { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum ToolIdErrorKind { - /// A component was empty. + /// The id did not have exactly 3 segments (`namespace/pack/name`). + SegmentCount, + /// A segment (or a wire name) was empty. Empty, - /// A component contained the `/` namespace separator. + /// A wire name contained the `/` namespace separator. Separator, - /// A component contained a control character. + /// A segment (or a wire name) contained a character outside the allowed + /// set. Control, } @@ -109,7 +126,7 @@ pub enum ToolIdErrorKind { #[error("invalid tool {field}: {reason}")] #[non_exhaustive] pub struct ToolIdError { - /// Which component was rejected (`server`, `name`, or `wire name`). + /// What was rejected (`id` for a parse failure, or `wire name`). field: &'static str, /// A stable classification of why it was rejected. kind: ToolIdErrorKind, @@ -124,7 +141,7 @@ impl ToolIdError { self.kind } - /// Returns which component was rejected (`server`, `name`, or `wire name`). + /// Returns what was rejected (`id` for a parse failure, or `wire name`). #[must_use] pub fn field(&self) -> &str { self.field @@ -135,13 +152,34 @@ impl ToolIdError { pub(crate) fn reason(&self) -> &'static str { self.reason } + + /// Maps a global-name rejection onto the tool-id error vocabulary. + fn from_global_name_kind(global_kind: GlobalNameErrorKind) -> ToolIdError { + let (kind, reason) = match global_kind { + GlobalNameErrorKind::SegmentCount => ( + ToolIdErrorKind::SegmentCount, + "a tool id must have exactly 3 segments (namespace/pack/name)", + ), + GlobalNameErrorKind::Empty => (ToolIdErrorKind::Empty, "segments must not be empty"), + GlobalNameErrorKind::Control => ( + ToolIdErrorKind::Control, + "segments may contain only lowercase ASCII letters, digits, '-', '_', '.'", + ), + }; + ToolIdError { + field: "id", + kind, + reason, + } + } } -/// Validates one identity-shaped component (server/name/wire name). +/// Validates one identity-shaped component (wire name). /// /// A component must be non-empty and free of the `/` namespace separator and any -/// control character. Shared so [`ToolId`] components and tool wire names are -/// held to one rule set (tools.rs F4). +/// control character. Tool identity itself is the 3-segment global grammar +/// ([`ToolId`]); this rule set remains for tool wire names, which are +/// single-segment transport tokens (tools.rs F4). pub(crate) fn validate_identifier(field: &'static str, value: &str) -> Result<(), ToolIdError> { if value.is_empty() { return Err(ToolIdError { diff --git a/crates/shared-promptforge-api/src/tools/registry.rs b/crates/shared-promptforge-api/src/tools/registry.rs index f0cfeef7..a47340ae 100644 --- a/crates/shared-promptforge-api/src/tools/registry.rs +++ b/crates/shared-promptforge-api/src/tools/registry.rs @@ -89,7 +89,7 @@ impl ToolCatalog { /// use shared_promptforge_api::tools::{ToolCatalog, ToolId}; /// /// let catalog = ToolCatalog::new(&[])?; - /// let missing = ToolId::new("promptforge", "missing")?; + /// let missing = ToolId::parse("promptforge/tools/missing")?; /// assert!(catalog.get(&missing).is_none()); /// # Ok::<(), Box>(()) /// ``` @@ -224,9 +224,9 @@ impl ToolCatalogError { /// } /// } /// -/// let echo = Echo { id: ToolId::new("example", "echo")? }; +/// let echo = Echo { id: ToolId::parse("example/echo/echo")? }; /// assert_eq!(echo.wire_name(), "echo"); -/// assert_eq!(echo.id().server(), "example"); +/// assert_eq!(echo.id().name(), "echo"); /// # let _ = OutputTrust::Trusted; /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) /// ``` diff --git a/crates/shared-promptforge-api/src/tools/tests.rs b/crates/shared-promptforge-api/src/tools/tests.rs index 176074e2..456879d7 100644 --- a/crates/shared-promptforge-api/src/tools/tests.rs +++ b/crates/shared-promptforge-api/src/tools/tests.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use serde_json::{Value, json}; use super::{Tool, ToolCatalog, ToolCatalogErrorKind, ToolError, ToolId, ToolOutput}; +use crate::names::GlobalName; fn inspect_id() -> ToolId { - ToolId::new("fixtures", "inspect").expect("fixture id is valid") + ToolId::parse("fixtures/tools/inspect").expect("fixture id is valid") } struct FixtureTool; @@ -53,7 +54,7 @@ struct CatalogFixtureTool { #[async_trait::async_trait] impl Tool for CatalogFixtureTool { fn id(&self) -> ToolId { - ToolId::new("fixtures", self.id_name).expect("fixture id is valid") + ToolId::parse(&format!("fixtures/tools/{}", self.id_name)).expect("fixture id is valid") } fn wire_name(&self) -> &str { @@ -160,7 +161,7 @@ fn catalog_lookup_uses_stable_identity_not_wire_name() { assert_eq!(found.wire_name(), "inspect_wire"); assert!( catalog - .get(&ToolId::new("fixtures", "inspect_wire").expect("valid id")) + .get(&ToolId::parse("fixtures/tools/inspect_wire").expect("valid id")) .is_none(), "the transport name must not become identity" ); @@ -220,45 +221,107 @@ fn catalog_rejects_duplicate_tool_ids() { ); } +fn tool_id_error_kind(input: &str) -> super::ToolIdErrorKind { + ToolId::parse(input) + .expect_err("the input must be rejected") + .kind() +} + +#[test] +fn a_three_segment_tool_id_parses_and_exposes_its_name() { + let id = ToolId::parse("promptforge/web/fetch").expect("a valid tool id"); + assert_eq!(id.name(), "fetch"); +} + +#[test] +fn a_tool_ids_capability_is_always_its_two_segment_prefix() { + let id = ToolId::parse("promptforge/web/fetch").expect("a valid tool id"); + assert_eq!( + id.capability(), + GlobalName::parse("promptforge/web").expect("a valid capability name"), + "dropping the last segment must yield the contributing capability's id" + ); +} + +#[test] +fn containment_holds_for_a_reverse_dns_namespace() { + let id = ToolId::parse("org.rustalliance/core/search").expect("a valid tool id"); + assert_eq!(id.name(), "search"); + assert_eq!(id.capability().to_string(), "org.rustalliance/core"); +} + #[test] -fn tool_id_new_rejects_empty_separator_and_control() { +fn a_two_segment_capability_name_is_rejected_as_a_tool_id() { use super::ToolIdErrorKind; + assert_eq!( + tool_id_error_kind("promptforge/web"), + ToolIdErrorKind::SegmentCount + ); +} +#[test] +fn a_single_segment_is_rejected_as_a_tool_id() { + use super::ToolIdErrorKind; assert_eq!( - ToolId::new("", "name").expect_err("empty server").kind(), - ToolIdErrorKind::Empty + tool_id_error_kind("promptforge"), + ToolIdErrorKind::SegmentCount ); +} + +#[test] +fn four_segments_are_rejected_as_a_tool_id() { + use super::ToolIdErrorKind; assert_eq!( - ToolId::new("server", "").expect_err("empty name").kind(), - ToolIdErrorKind::Empty + tool_id_error_kind("promptforge/web/fetch/extra"), + ToolIdErrorKind::SegmentCount ); +} + +#[test] +fn an_empty_segment_is_rejected_as_an_empty_error() { + use super::ToolIdErrorKind; assert_eq!( - ToolId::new("a/b", "name") - .expect_err("separator in server") - .kind(), - ToolIdErrorKind::Separator + tool_id_error_kind("promptforge//fetch"), + ToolIdErrorKind::Empty ); +} + +#[test] +fn a_control_character_is_rejected_as_a_control_error() { + use super::ToolIdErrorKind; assert_eq!( - ToolId::new("server", "a/b") - .expect_err("separator in name") - .kind(), - ToolIdErrorKind::Separator + tool_id_error_kind("promptforge/we\tb/fetch"), + ToolIdErrorKind::Control ); assert_eq!( - ToolId::new("server", "na\u{7f}me") - .expect_err("DEL control in name") - .kind(), + tool_id_error_kind("promptforge/web/fe\u{7f}tch"), ToolIdErrorKind::Control ); +} + +#[test] +fn an_uppercase_segment_is_rejected_because_comparison_is_case_sensitive() { + use super::ToolIdErrorKind; assert_eq!( - ToolId::new("ser\tver", "name") - .expect_err("tab control in server") - .kind(), + tool_id_error_kind("Promptforge/web/fetch"), ToolIdErrorKind::Control ); - // A provider-invalid but structurally legal identity is accepted here; - // provider acceptance is a runtime concern, not an identity invariant. - assert!(ToolId::new("promptforge", "web_search").is_ok()); +} + +#[test] +fn from_validated_builds_a_static_id_without_revalidating() { + let id = ToolId::from_validated("promptforge/web/search"); + assert_eq!(id.name(), "search"); + assert_eq!(id.capability().to_string(), "promptforge/web"); +} + +#[test] +fn the_migrated_built_in_ids_parse() { + // The built-ins moved from 2-part server/name onto the global grammar: + // promptforge/web_fetch -> promptforge/web/fetch and + // promptforge/web_search -> promptforge/web/search. + assert!(ToolId::parse("promptforge/web/fetch").is_ok()); + assert!(ToolId::parse("promptforge/web/search").is_ok()); } #[test] @@ -268,7 +331,7 @@ fn catalog_rejects_illegal_wire_name() { #[async_trait::async_trait] impl Tool for BadWire { fn id(&self) -> ToolId { - ToolId::new("fixtures", "bad_wire").expect("valid id") + ToolId::parse("fixtures/tools/bad_wire").expect("valid id") } #[expect( clippy::unnecessary_literal_bound, diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index b96a23f2..65ceadd6 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -876,7 +876,7 @@ New `names` module in `shared-promptforge-api`: `GlobalName` (private segments, -### Step 2: ToolId re-base and built-in id migration +### Step 2: ToolId re-base and built-in id migration [completed] - Component: global-names From 8bc2313e92d3608700ef7aa0b8c3153537c18707 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 19:27:01 -0700 Subject: [PATCH 03/30] Migrate the picker's ToolId onto the global name grammar The picker's tool identity is now the shared global naming grammar: one id type of three slash-separated segments crosses the picker boundary, so lint and discovery results need no translation. Catalog entries carry identity as one string field, and the pre-migration shapes, whether flat server and name fields or two-segment strings, are rejected at parse and at deserialization. Model identities, whose components are nearly arbitrary text, ride in the new ids through a byte-wise hex escape behind a neutral name segment. Duplicate detection now groups twins by capability prefix rather than by server. - `ToolId` in the picker crate is a re-export of the shared global-name type; the crate's own server/name pair struct is deleted, so one id type serves the picker, the executor, and the wire. - `crates/shared-promptforge-api/src/tools/ids.rs` gives `ToolId` its Display and serde impls: the canonical namespace/pack/name string is the serialized form, and deserialization validates, so an invalid string is a data error, never a silently accepted identity. - `model_to_picker_id` escapes each model id component byte-wise into the global-name charset, with the escape introducer itself encoded, and keeps a neutral name segment so vendor model ids stay out of the text the embedding sees. - `decide` groups duplicate twins by capability-prefix equality instead of server equality, so the duplicate outcome reports one capability's copies of a tool. - `crates/promptforge-api/src/resolve.rs` forwards selected ids to the picker verbatim and clones ids out of picker outcomes; no string splitting or reconstruction remains on either side of the boundary. - `validate` on model id components now rejects control scalars only; the explicit picker record-separator case is gone because the grammar charset refuses such bytes at parse. - `tool_id_of` and the id conversion inside the near-duplicates forwarding are removed; the translation layer between the two former id types no longer exists. - `the_legacy_flat_identity_shape_is_rejected` pins the absence of a dual-read: the flat server/name catalog shape and two-segment id strings fail deserialization outright. Design: removes parallel-abstraction @ crates/promptforge-tool-picker/src/catalog.rs::ToolId Design: removes parallel-abstraction @ crates/promptforge-api/src/resolve.rs::tool_id_of Design: new schema-change @ crates/promptforge-tool-picker/src/catalog.rs::ToolDescriptor boundary: wire Design: new surface-growth @ crates/shared-promptforge-api/src/tools/ids.rs::ToolId boundary: wire Design: new pure-function @ crates/promptforge-model-client/src/model.rs::escape_segment deps: str Design: new pure-function @ crates/promptforge-model-client/src/model.rs::unescape_segment deps: str Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- Cargo.lock | 1 + .../src/execute/tests/live_infer.rs | 2 +- .../promptforge-api/src/execute/tests/mod.rs | 7 +- .../src/execute/tests/observations.rs | 2 +- crates/promptforge-api/src/resolve.rs | 92 +++------ crates/promptforge-model-client/src/model.rs | 89 ++++++-- .../src/model/tests.rs | 27 +++ crates/promptforge-tool-picker/Cargo.toml | 3 + crates/promptforge-tool-picker/src/catalog.rs | 190 +++++++++--------- crates/promptforge-tool-picker/src/error.rs | 2 +- crates/promptforge-tool-picker/src/lib.rs | 4 +- crates/promptforge-tool-picker/src/picker.rs | 4 +- .../src/picker/tests.rs | 27 ++- crates/promptforge-tool-picker/src/policy.rs | 13 +- .../src/policy/tests.rs | 43 ++-- .../promptforge-tool-picker/src/selected.rs | 11 +- .../tests/fixtures/mixed-servers.json | 15 +- .../tests/it/behavior.rs | 45 +++-- .../tests/it/public_api.rs | 29 +-- crates/shared-promptforge-api/src/models.rs | 17 +- .../shared-promptforge-api/src/tools/ids.rs | 24 +++ .../shared-promptforge-api/src/tools/tests.rs | 17 ++ ...2026-09-13-1-capabilities-global-naming.md | 2 +- 23 files changed, 382 insertions(+), 284 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0bb16e64..a8824638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4908,6 +4908,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "shared-progress", + "shared-promptforge-api", "thiserror 2.0.19", "tokenizers", ] diff --git a/crates/promptforge-api/src/execute/tests/live_infer.rs b/crates/promptforge-api/src/execute/tests/live_infer.rs index 06bf9f2e..1fa929bc 100644 --- a/crates/promptforge-api/src/execute/tests/live_infer.rs +++ b/crates/promptforge-api/src/execute/tests/live_infer.rs @@ -148,7 +148,7 @@ async fn shared_library_calls_host_apis_at_load_time() { async fn captured_bindings_reach_section_call_and_fanout_vms() { let echo = Arc::new(EchoTool); let descriptor = ToolDescriptor::new( - PickerToolId::new("tests/tools", "echo"), + PickerToolId::parse("tests/tools/echo").expect("fixture id is valid"), echo.description(), echo.parameters_schema(), ); diff --git a/crates/promptforge-api/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs index c46c75b7..d9f8e2c9 100644 --- a/crates/promptforge-api/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -331,12 +331,7 @@ async fn run( tools .iter() .map(|tool| { - let id = tool.id(); - ToolDescriptor::new( - PickerToolId::new(id.capability().to_string(), id.name()), - tool.description(), - tool.parameters_schema(), - ) + ToolDescriptor::new(tool.id(), tool.description(), tool.parameters_schema()) }) .collect(), ) diff --git a/crates/promptforge-api/src/execute/tests/observations.rs b/crates/promptforge-api/src/execute/tests/observations.rs index 71842de2..33f7ef4b 100644 --- a/crates/promptforge-api/src/execute/tests/observations.rs +++ b/crates/promptforge-api/src/execute/tests/observations.rs @@ -284,7 +284,7 @@ async fn one_execution_id_spans_parse_and_the_complete_runtime_lifecycle() { "Echo a test value.", )); let descriptor = ToolDescriptor::new( - PickerToolId::new("tests/tools", "echo"), + PickerToolId::parse("tests/tools/echo").expect("fixture id is valid"), tool.description(), tool.parameters_schema(), ); diff --git a/crates/promptforge-api/src/resolve.rs b/crates/promptforge-api/src/resolve.rs index f8b8ae0d..b69b214d 100644 --- a/crates/promptforge-api/src/resolve.rs +++ b/crates/promptforge-api/src/resolve.rs @@ -5,8 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use mlua::{Lua, Scope}; use promptforge_model_client::Error as GatewayClientError; -use promptforge_tool_picker::ToolId as PickerToolId; -use promptforge_tool_picker::{Outcome, ToolDescriptor, ToolPicker}; +use promptforge_tool_picker::{Outcome, ToolPicker}; use crate::error::SharedSource; use crate::lua::{LiveBindingProducer, ToolResolver, ToolSet}; @@ -119,12 +118,13 @@ impl ModelResolver for RuntimeResolution<'_> { } } -/// A resolved capability outcome, normalized once into core-owned identities. +/// A resolved capability outcome, normalized once into owned identities. /// -/// Picker [`ToolDescriptor`]s are converted to core [`ToolId`]s at decision -/// time (F4), so a cached decision holds only the stable identities the caller -/// needs; a cache hit produces its typed result from these borrowed ids without -/// re-cloning full descriptors on every resolve. +/// Picker descriptor ids are cloned into owned [`ToolId`]s at decision +/// time (F4) - the picker and the executor speak one id type, so no +/// translation is needed - so a cached decision holds only the stable +/// identities the caller needs; a cache hit produces its typed result from +/// these owned ids without re-cloning full descriptors on every resolve. #[derive(Debug)] enum CachedDecision { Bind(ToolId), @@ -144,29 +144,18 @@ enum CachedDecision { NoPicker, } -/// Converts a borrowed picker descriptor to a core-owned [`ToolId`]. -/// -/// The picker still speaks 2-part ids (its own migration onto the global -/// grammar is a later step): a core id rides through the picker as server = -/// the capability prefix (`namespace/pack`, which the picker's server part -/// accepts verbatim, separators included) and name = the tool name, so the -/// round trip is lossless. -fn tool_id_of(tool: &ToolDescriptor) -> ToolId { - ToolId::from_validated(&format!("{}/{}", tool.id().server(), tool.id().name())) -} - impl CachedDecision { fn from_picker( outcome: std::result::Result, promptforge_tool_picker::QueryError>, ) -> Self { match outcome { - Ok(Outcome::Bind(tool)) => Self::Bind(tool_id_of(tool)), + Ok(Outcome::Bind(tool)) => Self::Bind(tool.id().clone()), Ok(Outcome::Absent) => Self::Absent, Ok(Outcome::Duplicate(group)) => { - Self::Duplicate(group.iter().map(tool_id_of).collect()) + Self::Duplicate(group.iter().map(|tool| tool.id().clone()).collect()) } Ok(Outcome::Ambiguous(group)) => { - Self::Ambiguous(group.iter().map(tool_id_of).collect()) + Self::Ambiguous(group.iter().map(|tool| tool.id().clone()).collect()) } Ok(_) => Self::Unrecognized, Err(error) => Self::QueryFailed(SharedSource::new(error)), @@ -211,8 +200,8 @@ trait DecisionSource: Send + Sync { /// source (F4). fn near_duplicates( &self, - ids: &[PickerToolId], - ) -> std::result::Result, SharedSource>; + ids: &[ToolId], + ) -> std::result::Result, SharedSource>; } impl DecisionSource for ToolPicker { @@ -222,8 +211,8 @@ impl DecisionSource for ToolPicker { fn near_duplicates( &self, - ids: &[PickerToolId], - ) -> std::result::Result, SharedSource> { + ids: &[ToolId], + ) -> std::result::Result, SharedSource> { ToolPicker::near_duplicates(self, ids) .map(|pairs| { pairs @@ -253,8 +242,8 @@ impl DecisionSource for NoPicker { fn near_duplicates( &self, - _ids: &[PickerToolId], - ) -> std::result::Result, SharedSource> { + _ids: &[ToolId], + ) -> std::result::Result, SharedSource> { Ok(Vec::new()) } } @@ -326,31 +315,14 @@ where &self, ids: &[ToolId], ) -> std::result::Result, promptforge_lua::Error> { - let picker_ids = ids - .iter() - .map(|id| PickerToolId::new(id.capability().to_string(), id.name())) - .collect::>(); - self.source - .near_duplicates(&picker_ids) - .map(|pairs| { - pairs - .into_iter() - .map(|(first, second, similarity)| { - ( - ToolId::from_validated(&format!("{}/{}", first.server(), first.name())), - ToolId::from_validated(&format!( - "{}/{}", - second.server(), - second.name() - )), - similarity, - ) - }) - .collect() - }) - .map_err(|source| promptforge_lua::Error::ToolScopeAnalysisSource { + // One id type on both sides of the picker boundary: the selected + // identities forward verbatim and the reported pairs need no + // reconstruction. + self.source.near_duplicates(ids).map_err(|source| { + promptforge_lua::Error::ToolScopeAnalysisSource { source: Box::new(source), - }) + } + }) } } @@ -388,8 +360,8 @@ mod tests { fn near_duplicates( &self, - ids: &[PickerToolId], - ) -> std::result::Result, SharedSource> { + ids: &[ToolId], + ) -> std::result::Result, SharedSource> { Ok(vec![(ids[0].clone(), ids[1].clone(), 0.97)]) } } @@ -415,9 +387,8 @@ mod tests { fn near_duplicates( &self, - ids: &[PickerToolId], - ) -> std::result::Result, SharedSource> - { + ids: &[ToolId], + ) -> std::result::Result, SharedSource> { Ok(vec![(ids[0].clone(), ids[1].clone(), 0.0)]) } } @@ -646,10 +617,7 @@ mod tests { #[test] fn near_duplicates_are_forwarded_from_the_source() { - let ids = [ - PickerToolId::new("tests", "first"), - PickerToolId::new("tests", "second"), - ]; + let ids = [tid("first"), tid("second")]; let pairs = FixtureSource .near_duplicates(&ids) .expect("analysis succeeds"); @@ -696,8 +664,8 @@ mod tests { fn near_duplicates( &self, - _ids: &[PickerToolId], - ) -> std::result::Result, SharedSource> { + _ids: &[ToolId], + ) -> std::result::Result, SharedSource> { Ok(Vec::new()) } } diff --git a/crates/promptforge-model-client/src/model.rs b/crates/promptforge-model-client/src/model.rs index d990d216..04cfbfbe 100644 --- a/crates/promptforge-model-client/src/model.rs +++ b/crates/promptforge-model-client/src/model.rs @@ -65,10 +65,10 @@ impl ModelCatalogFiltered for ModelCatalog { /// /// The picker's `enriched_text` prefixes the tool name, so vendor model ids /// must not ride in that name or they drown the capability description. -/// Identity is encoded in the picker id's server field; every entry uses a -/// single neutral, crate-private label. Accepting borrowed descriptors lets a -/// filtered view build a picker without first cloning matches into an owned -/// catalog (MODEL-017). +/// Identity is escaped into the picker id's first two segments; every entry +/// uses a single neutral, crate-private label as its name. Accepting borrowed +/// descriptors lets a filtered view build a picker without first cloning +/// matches into an owned catalog (MODEL-017). pub(crate) fn picker_catalog_from<'a>( models: impl IntoIterator, ) -> Catalog { @@ -89,23 +89,82 @@ pub(crate) fn picker_catalog_from<'a>( /// Neutral picker name so `enriched_text` does not inject vendor model ids. const PICKER_MODEL_LABEL: &str = "model"; -/// Separates server and model name inside the picker's server field. -const PICKER_ID_SEPARATOR: char = '\u{1e}'; - +/// Encodes a model identity as a picker id in the global naming grammar. +/// +/// A picker id is a 3-segment global name (`namespace/pack/name`) over a +/// lowercase ASCII charset, while a model id component is nearly arbitrary +/// text, so each component rides in one of the first two segments escaped +/// byte-wise: charset bytes pass through and every other byte - including the +/// escape introducer `-` itself - is emitted as `-` plus two lowercase hex +/// digits. The name segment is the neutral label. fn model_to_picker_id(id: &ModelId) -> PickerToolId { - PickerToolId::new( - format!("{}{}{}", id.server(), PICKER_ID_SEPARATOR, id.name()), - PICKER_MODEL_LABEL, - ) + PickerToolId::from_validated(&format!( + "{}/{}/{}", + escape_segment(id.server()), + escape_segment(id.name()), + PICKER_MODEL_LABEL + )) } +/// Recovers the model identity encoded by [`model_to_picker_id`]. +/// +/// The escape is total, so a self-produced id always decodes; a foreign id +/// that does not decode falls back to its raw first two segments, mirroring +/// the pre-migration defensive path. pub(crate) fn model_from_picker_id(id: &PickerToolId) -> ModelId { - match id.server().split_once(PICKER_ID_SEPARATOR) { - Some((server, name)) if !server.is_empty() && !name.is_empty() => { - ModelId::from_validated(server, name) + let capability = id.capability(); + match ( + unescape_segment(capability.namespace()), + unescape_segment(capability.pack()), + ) { + (Some(server), Some(name)) => ModelId::from_validated(server, name), + _ => ModelId::from_validated(capability.namespace(), capability.pack()), + } +} + +/// Encodes one model id component into a global-name segment; see +/// [`model_to_picker_id`] for the scheme. +fn escape_segment(component: &str) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(component.len()); + for byte in component.bytes() { + if matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.') { + out.push(char::from(byte)); + } else { + out.push('-'); + out.push(char::from(HEX[(byte >> 4) as usize])); + out.push(char::from(HEX[(byte & 0x0f) as usize])); + } + } + out +} + +/// Decodes one segment produced by [`escape_segment`]. Returns `None` for a +/// segment that is not escape output (a dangling `-` or non-UTF-8 bytes). +fn unescape_segment(segment: &str) -> Option { + fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } + } + + let bytes = segment.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'-' { + let high = hex_digit(*bytes.get(index + 1)?)?; + let low = hex_digit(*bytes.get(index + 2)?)?; + out.push(high << 4 | low); + index += 3; + } else { + out.push(bytes[index]); + index += 1; } - _ => ModelId::from_validated(id.server(), id.name()), } + String::from_utf8(out).ok() } /// Resolves one `models.bind` description under optional hard constraints. diff --git a/crates/promptforge-model-client/src/model/tests.rs b/crates/promptforge-model-client/src/model/tests.rs index 46666ced..d4488247 100644 --- a/crates/promptforge-model-client/src/model/tests.rs +++ b/crates/promptforge-model-client/src/model/tests.rs @@ -94,6 +94,33 @@ fn same_weights_different_invocation_compare_unequal() { assert_ne!(a.invocation(), b.invocation()); } +#[test] +fn model_picker_ids_round_trip_through_the_global_name_grammar() { + for name in ["small", "analyst", "always-think"] { + let id = gateway_id(name); + let picker_id = model_to_picker_id(&id); + // The neutral name segment keeps vendor ids out of `enriched_text`. + assert_eq!(picker_id.name(), PICKER_MODEL_LABEL); + assert_eq!(model_from_picker_id(&picker_id), id); + } +} + +#[test] +fn model_picker_ids_escape_characters_outside_the_grammar() { + // Model id components are nearly arbitrary text: uppercase, `/`, and + // spaces are all legal there but illegal in a global-name segment. + let id = ModelId::new("Gateway.Local", "Qwen/Qwen3 32B").expect("a valid model id"); + let picker_id = model_to_picker_id(&id); + let text = picker_id.to_string(); + assert!( + text.bytes().all(|b| b.is_ascii_lowercase() + || b.is_ascii_digit() + || matches!(b, b'/' | b'-' | b'_' | b'.')), + "the encoded id must satisfy the global-name charset: {text}" + ); + assert_eq!(model_from_picker_id(&picker_id), id); +} + #[test] fn binding_construction_is_atomic_with_context() { let binding = ModelBinding::new( diff --git a/crates/promptforge-tool-picker/Cargo.toml b/crates/promptforge-tool-picker/Cargo.toml index 55e73502..5c3b6414 100644 --- a/crates/promptforge-tool-picker/Cargo.toml +++ b/crates/promptforge-tool-picker/Cargo.toml @@ -16,6 +16,9 @@ documentation = "https://cppalliance.github.io/promptforge/" candle-core = { workspace = true } candle-nn = { workspace = true } candle-transformers = { workspace = true } +# The global naming grammar (GlobalName, the canonical ToolId) the picker's +# catalog identities speak. +shared-promptforge-api.workspace = true shared-progress.workspace = true serde = { workspace = true, optional = true } serde_json = { workspace = true } diff --git a/crates/promptforge-tool-picker/src/catalog.rs b/crates/promptforge-tool-picker/src/catalog.rs index 8b6b9b3f..78395ab6 100644 --- a/crates/promptforge-tool-picker/src/catalog.rs +++ b/crates/promptforge-tool-picker/src/catalog.rs @@ -3,9 +3,10 @@ //! A catalog is the set of tool descriptors the engine may choose from, each //! carrying the identity and prose that the embedding is derived from. //! -//! Identity is the `(server, name)` pair, modelled as [`ToolId`]. The pair is -//! kept structural rather than folded into one string, so a server or tool name -//! containing any delimiter stays unambiguous. There is no concatenated key. +//! Identity is the global naming grammar's [`ToolId`]: exactly three +//! `/`-separated segments (`namespace/pack/name`), whose first two segments +//! name the capability that contributes the tool. The picker speaks the same +//! ids as the executor, so lint and discovery results need no translation. //! //! The prose an embedding sees is the descriptor's internal enriched text: the //! tool name with its underscores opened out, its description, and the names of @@ -14,56 +15,7 @@ use serde_json::Value; -/// The stable identity of a tool: the server it lives on and its name there. -/// -/// Two descriptors denote the same tool exactly when their identities compare -/// equal, and equality is structural over the pair - the parts are never -/// concatenated. A tool name is only unique within its server, so the server is -/// part of the identity, not context around it. -/// -/// # Examples -/// -/// ``` -/// use promptforge_tool_picker::ToolId; -/// -/// let id = ToolId::new("files", "read_file"); -/// assert_eq!(id.server(), "files"); -/// assert_eq!(id.name(), "read_file"); -/// // A delimiter inside either part never collides two identities. -/// assert_ne!(ToolId::new("a/b", "c"), ToolId::new("a", "b/c")); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[non_exhaustive] -pub struct ToolId { - /// The server the tool is served from. - server: String, - /// The tool's name within that server. - name: String, -} - -impl ToolId { - /// Builds an identity from a server and a tool name. - #[must_use] - pub fn new(server: impl Into, name: impl Into) -> Self { - Self { - server: server.into(), - name: name.into(), - } - } - - /// Returns the server the tool is served from. - #[must_use] - pub fn server(&self) -> &str { - &self.server - } - - /// Returns the tool's name within its server. - #[must_use] - pub fn name(&self) -> &str { - &self.name - } -} +pub use shared_promptforge_api::tools::ToolId; /// The MCP behavioural hints a tool may advertise about itself. /// @@ -174,9 +126,9 @@ impl ToolAnnotations { /// catalog input data, not an embedding-backend detail. The crate never /// validates or executes it. /// -/// In JSON the identity is flat, so a catalog entry reads as -/// `{"server": ..., "name": ..., "description": ..., "input_schema": ...}`. The -/// schema field also accepts its MCP spelling, `inputSchema`. +/// In JSON the identity is one global-name string, so a catalog entry reads as +/// `{"id": "namespace/pack/name", "description": ..., "input_schema": ...}`. +/// The schema field also accepts its MCP spelling, `inputSchema`. /// /// # Examples /// @@ -185,19 +137,19 @@ impl ToolAnnotations { /// use serde_json::json; /// /// let tool = ToolDescriptor::new( -/// ToolId::new("files", "read_file"), +/// ToolId::parse("files/fs/read_file")?, /// "Read a file from disk", /// json!({"properties": {"path": {"type": "string"}}}), /// ); /// assert_eq!(tool.name(), "read_file"); /// assert_eq!(tool.description(), "Read a file from disk"); +/// # Ok::<(), Box>(()) /// ``` #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[non_exhaustive] pub struct ToolDescriptor { /// The tool's stable identity. - #[cfg_attr(feature = "serde", serde(flatten))] id: ToolId, /// Prose describing what the tool does, as its author wrote it. description: String, @@ -234,13 +186,7 @@ impl ToolDescriptor { &self.id } - /// Returns the server the tool is served from. - #[must_use] - pub fn server(&self) -> &str { - self.id.server() - } - - /// Returns the tool's name within its server. + /// Returns the tool's name segment (the last of the identity's three). #[must_use] pub fn name(&self) -> &str { self.id.name() @@ -344,12 +290,13 @@ pub type CatalogIntoIter = std::vec::IntoIter; /// use serde_json::json; /// /// let catalog = Catalog::new(vec![ToolDescriptor::new( -/// ToolId::new("files", "read_file"), +/// ToolId::parse("files/fs/read_file")?, /// "Read a file from disk", /// json!({"properties": {"path": {"type": "string"}}}), /// )]); /// assert_eq!(catalog.len(), 1); /// assert_eq!(catalog.iter().next().map(ToolDescriptor::name), Some("read_file")); +/// # Ok::<(), Box>(()) /// ``` #[derive(Debug, Clone, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -446,13 +393,15 @@ impl From> for Catalog { mod tests { use super::{Catalog, ToolAnnotations, ToolDescriptor, ToolId}; use serde_json::{Value, json}; + use shared_promptforge_api::tools::ToolIdErrorKind; + + /// Parses a test identity: every test id is a valid global tool name. + fn tid(pack: &str, name: &str) -> ToolId { + ToolId::parse(&format!("tests/{pack}/{name}")).expect("test ids are valid global names") + } fn descriptor(schema: Value) -> ToolDescriptor { - ToolDescriptor::new( - ToolId::new("files", "read_file"), - "Read a file from disk", - schema, - ) + ToolDescriptor::new(tid("files", "read_file"), "Read a file from disk", schema) } #[test] @@ -483,7 +432,7 @@ mod tests { #[test] fn empty_description_does_not_double_the_separator() { let tool = ToolDescriptor::new( - ToolId::new("files", "read_file"), + tid("files", "read_file"), "", json!({"properties": {"path": {"type": "string"}}}), ); @@ -493,7 +442,7 @@ mod tests { #[test] fn a_description_ending_in_a_period_keeps_the_doubled_period() { let tool = ToolDescriptor::new( - ToolId::new("files", "read_file"), + tid("files", "read_file"), "Read a file from disk.", json!({"properties": {"path": {}, "encoding": {}}}), ); @@ -505,7 +454,7 @@ mod tests { #[test] fn a_tool_without_parameters_omits_the_parameters_part() { - let tool = ToolDescriptor::new(ToolId::new("meta", "list_tools"), "List tools.", json!({})); + let tool = ToolDescriptor::new(tid("meta", "list_tools"), "List tools.", json!({})); assert_eq!(tool.enriched_text(), "list tools. List tools."); } @@ -526,22 +475,44 @@ mod tests { } #[test] - fn identity_is_the_server_and_name_pair() { - let id = ToolId::new("files", "read_file"); - assert_eq!(id, ToolId::new("files", "read_file")); - assert_ne!(id, ToolId::new("blobs", "read_file")); - assert_ne!(id, ToolId::new("files", "write_file")); - assert_eq!(id.server(), "files"); + fn identity_is_a_three_segment_global_name() { + let id = tid("files", "read_file"); + assert_eq!(id, tid("files", "read_file")); + assert_ne!(id, tid("blobs", "read_file")); + assert_ne!(id, tid("files", "write_file")); assert_eq!(id.name(), "read_file"); + assert_eq!(id.capability().to_string(), "tests/files"); + assert_eq!(id.to_string(), "tests/files/read_file"); } #[test] - fn identities_with_a_delimiter_do_not_collide() { - assert_ne!( - ToolId::new("a\u{1f}b", "c"), - ToolId::new("a", "b\u{1f}c"), - "structural identity keeps a delimiter-bearing pair distinct" - ); + fn two_part_ids_from_before_the_migration_are_rejected() { + // The pre-migration shapes: a bare server/name pair and the old + // underscore-joined built-ins. + for legacy in [ + "files/read_file", + "promptforge/web_fetch", + "promptforge/web_search", + ] { + let error = ToolId::parse(legacy).expect_err("a 2-segment legacy id is not a tool id"); + assert_eq!(error.kind(), ToolIdErrorKind::SegmentCount); + } + } + + #[test] + fn ids_outside_the_global_name_charset_are_rejected() { + // A delimiter-bearing pair was representable before the migration; + // the grammar's charset now rejects it at parse. + for invalid in [ + "tests/files/read file", + "tests/Files/read_file", + "a\u{1f}b/c", + ] { + assert!( + ToolId::parse(invalid).is_err(), + "{invalid:?} must be rejected" + ); + } } #[test] @@ -561,19 +532,19 @@ mod tests { assert_eq!(empty.len(), 0); let first = descriptor(json!({})); - let second = ToolDescriptor::new(ToolId::new("net", "fetch"), "Fetch a URL", json!({})); + let second = ToolDescriptor::new(tid("net", "fetch"), "Fetch a URL", json!({})); let catalog = Catalog::new(vec![first.clone(), second.clone()]); assert_eq!(catalog.len(), 2); assert_eq!(catalog.iter().collect::>(), vec![&first, &second]); assert_eq!(catalog.get(second.id()), Some(&second)); - assert_eq!(catalog.get(&ToolId::new("net", "missing")), None); + assert_eq!(catalog.get(&tid("net", "missing")), None); } #[test] fn catalog_iterates_mutably_and_owns_from_vec_and_from_iter() { let mut catalog = Catalog::from(vec![ - ToolDescriptor::new(ToolId::new("a", "one"), "one", json!({})), - ToolDescriptor::new(ToolId::new("b", "two"), "two", json!({})), + ToolDescriptor::new(tid("a", "one"), "one", json!({})), + ToolDescriptor::new(tid("b", "two"), "two", json!({})), ]); for tool in &mut catalog { *tool = tool @@ -592,16 +563,15 @@ mod tests { #[cfg(feature = "serde")] #[test] - fn descriptor_deserializes_from_a_flat_mcp_shaped_object() { + fn descriptor_deserializes_from_an_mcp_shaped_object() { let parsed: ToolDescriptor = serde_json::from_value(json!({ - "server": "files", - "name": "read_file", + "id": "tests/files/read_file", "description": "Read a file from disk", "inputSchema": {"properties": {"path": {"type": "string"}}}, "annotations": {"readOnlyHint": true} })) - .expect("flat MCP descriptor deserializes"); - assert_eq!(parsed.id(), &ToolId::new("files", "read_file")); + .expect("an MCP-shaped descriptor deserializes"); + assert_eq!(parsed.id(), &tid("files", "read_file")); assert_eq!(parsed.annotations().read_only(), Some(true)); assert_eq!(parsed.annotations().destructive(), None); } @@ -610,8 +580,7 @@ mod tests { #[test] fn absent_optional_fields_default() { let parsed: ToolDescriptor = serde_json::from_value(json!({ - "server": "files", - "name": "read_file", + "id": "tests/files/read_file", "description": "Read a file from disk" })) .expect("descriptor with absent optionals deserializes"); @@ -619,13 +588,38 @@ mod tests { assert_eq!(parsed.annotations(), ToolAnnotations::default()); } + #[cfg(feature = "serde")] + #[test] + fn the_legacy_flat_identity_shape_is_rejected() { + // The pre-migration wire shape carried identity as separate + // server/name fields; the migrated shape is one global-name string. + assert!( + serde_json::from_value::(json!({ + "server": "files", + "name": "read_file", + "description": "Read a file from disk" + })) + .is_err(), + "the flat server/name shape is gone" + ); + // A 2-segment string is not a tool id either. + assert!( + serde_json::from_value::(json!({ + "id": "files/read_file", + "description": "Read a file from disk" + })) + .is_err(), + "a 2-segment id fails validation at deserialization" + ); + } + #[cfg(feature = "serde")] #[test] fn catalog_round_trips_as_an_array() { let catalog = Catalog::new(vec![ descriptor(json!({"properties": {"path": {"type": "string"}}})) .with_annotations(ToolAnnotations::new().with_read_only(true)), - ToolDescriptor::new(ToolId::new("net", "fetch"), "Fetch a URL", json!({})), + ToolDescriptor::new(tid("net", "fetch"), "Fetch a URL", json!({})), ]); let text = serde_json::to_string(&catalog).expect("serialize"); assert!(text.starts_with('['), "a catalog serializes as an array"); diff --git a/crates/promptforge-tool-picker/src/error.rs b/crates/promptforge-tool-picker/src/error.rs index 3ecad194..8047df79 100644 --- a/crates/promptforge-tool-picker/src/error.rs +++ b/crates/promptforge-tool-picker/src/error.rs @@ -270,7 +270,7 @@ mod tests { #[test] fn a_selection_error_reports_the_missing_identity() { - let missing = ToolId::new("files", "read_file"); + let missing = ToolId::parse("tests/files/read_file").expect("test id is valid"); let error = SelectionError::new(missing.clone()); assert_eq!(error.missing_id(), &missing); } diff --git a/crates/promptforge-tool-picker/src/lib.rs b/crates/promptforge-tool-picker/src/lib.rs index eeee2e79..b4c46095 100644 --- a/crates/promptforge-tool-picker/src/lib.rs +++ b/crates/promptforge-tool-picker/src/lib.rs @@ -3,7 +3,7 @@ //! This crate is a pure, deterministic, embedding-based tool-resolution engine. //! It takes a [`Catalog`] of [`ToolDescriptor`] values, embeds each one locally //! on the CPU with a reusable [`Model`], and answers a need with one of four -//! borrowing outcomes: a single bound tool, a group of one server's own +//! borrowing outcomes: a single bound tool, a group of one capability's own //! duplicate tools to fail loudly on, a shortlist of candidates it could not //! separate, or an abstention when nothing fits. //! @@ -27,7 +27,7 @@ //! use serde_json::json; //! //! let catalog = Catalog::new(vec![ToolDescriptor::new( -//! ToolId::new("files", "read_file"), +//! ToolId::parse("files/fs/read_file")?, //! "Read a file from disk", //! json!({"properties": {"path": {"type": "string"}}}), //! )]); diff --git a/crates/promptforge-tool-picker/src/picker.rs b/crates/promptforge-tool-picker/src/picker.rs index d907cb27..312ec902 100644 --- a/crates/promptforge-tool-picker/src/picker.rs +++ b/crates/promptforge-tool-picker/src/picker.rs @@ -60,13 +60,13 @@ impl ToolPicker { /// use serde_json::json; /// /// let catalog = Catalog::new(vec![ToolDescriptor::new( - /// ToolId::new("files", "read_file"), + /// ToolId::parse("files/fs/read_file")?, /// "Read a file from disk", /// json!({"properties": {"path": {"type": "string"}}}), /// )]); /// let picker = ToolPicker::build(catalog, Config::default())?; /// assert_eq!(picker.len(), 1); - /// # Ok::<(), promptforge_tool_picker::BuildError>(()) + /// # Ok::<(), Box>(()) /// ``` #[must_use = "a picker that is built and dropped did its costly work for nothing"] pub fn build(catalog: Catalog, config: Config) -> Result { diff --git a/crates/promptforge-tool-picker/src/picker/tests.rs b/crates/promptforge-tool-picker/src/picker/tests.rs index 7a8984e9..eb760641 100644 --- a/crates/promptforge-tool-picker/src/picker/tests.rs +++ b/crates/promptforge-tool-picker/src/picker/tests.rs @@ -23,30 +23,35 @@ fn picker(catalog: Catalog, config: Config) -> ToolPicker { ToolPicker::build_with_model(model(), catalog, config, None).expect("the shared model indexes") } +/// Parses a test identity: every test id is a valid global tool name. +fn tid(pack: &str, name: &str) -> ToolId { + ToolId::parse(&format!("tests/{pack}/{name}")).expect("test ids are valid global names") +} + fn tiny_catalog() -> Catalog { Catalog::new(vec![ ToolDescriptor::new( - ToolId::new("files", "read_file"), + tid("files", "read_file"), "Read a file from disk", json!({"properties": {"path": {"type": "string"}}}), ), ToolDescriptor::new( - ToolId::new("net", "fetch_url"), + tid("net", "fetch_url"), "Fetch a web page over HTTP", json!({"properties": {"url": {"type": "string"}}}), ), ]) } -/// The same tool published twice, under the given servers. +/// The same tool published twice, under the given packs. fn republished(first: &str, second: &str) -> Catalog { let tool = ToolDescriptor::new( - ToolId::new(first, "read_file"), + tid(first, "read_file"), "Read a file from disk", json!({"properties": {"path": {"type": "string"}}}), ); let twin = ToolDescriptor::new( - ToolId::new(second, "read_file"), + tid(second, "read_file"), "Read a file from disk", json!({"properties": {"path": {"type": "string"}}}), ); @@ -195,7 +200,7 @@ fn a_need_no_tool_covers_abstains_and_shortlists_nothing() { } #[test] -fn a_tool_republished_on_one_server_is_a_duplicate_and_across_two_is_ambiguous() { +fn a_tool_republished_in_one_capability_is_a_duplicate_and_across_two_is_ambiguous() { let same = picker(republished("files", "files"), Config::default()); let need = "read a file from disk"; assert!(matches!( @@ -233,15 +238,15 @@ fn near_duplicates_reuses_the_indexed_vectors_inclusively() { let pairs = picker.near_duplicates(&ids).expect("analysis"); assert_eq!(pairs.len(), 1); let pair = pairs.get(0).expect("one pair"); - assert_eq!(pair.first().server(), "files"); - assert_eq!(pair.second().server(), "blobs"); + assert_eq!(pair.first().id().capability().to_string(), "tests/files"); + assert_eq!(pair.second().id().capability().to_string(), "tests/blobs"); assert!(pair.similarity() >= picker.config().duplicate_threshold()); } #[test] fn near_duplicates_rejects_an_absent_identity() { let picker = picker(tiny_catalog(), Config::default()); - let missing = ToolId::new("missing", "tool"); + let missing = tid("missing", "tool"); let error = picker .near_duplicates(std::slice::from_ref(&missing)) .expect_err("absent"); @@ -253,11 +258,11 @@ fn get_returns_the_first_matching_descriptor() { let picker = picker(tiny_catalog(), Config::default()); assert_eq!( picker - .get(&ToolId::new("net", "fetch_url")) + .get(&tid("net", "fetch_url")) .map(ToolDescriptor::name), Some("fetch_url") ); - assert_eq!(picker.get(&ToolId::new("net", "absent")), None); + assert_eq!(picker.get(&tid("net", "absent")), None); } #[test] diff --git a/crates/promptforge-tool-picker/src/policy.rs b/crates/promptforge-tool-picker/src/policy.rs index b572c3dc..54119ba8 100644 --- a/crates/promptforge-tool-picker/src/policy.rs +++ b/crates/promptforge-tool-picker/src/policy.rs @@ -20,9 +20,10 @@ use crate::rank::{Candidate, Vectors, comparable}; /// [`Outcome::Absent`] is a successful abstention: nothing cleared the floor. /// [`Outcome::Bind`] is a single tool that cleared the floor and left the /// runner-up behind by at least the margin. [`Outcome::Duplicate`] reports a -/// group of at least two same-server twins - a fault in one server's catalog. -/// [`Outcome::Ambiguous`] reports every other near-tie the margin could not -/// separate, most often one tool republished across two servers. +/// group of at least two same-capability twins - a fault in one capability's +/// catalog. [`Outcome::Ambiguous`] reports every other near-tie the margin +/// could not separate, most often one tool republished across two +/// capabilities. /// /// # The solo-candidate rule /// @@ -44,7 +45,7 @@ use crate::rank::{Candidate, Vectors, comparable}; /// let picker = ToolPicker::build(Catalog::default(), Config::default())?; /// match picker.resolve("read a file from disk")? { /// Outcome::Bind(tool) => println!("call {}", tool.name()), -/// Outcome::Duplicate(group) => println!("{} publishes twins", group.first().server()), +/// Outcome::Duplicate(group) => println!("{} publishes twins", group.first().id().capability()), /// Outcome::Ambiguous(group) => println!("{} tools fit", group.len()), /// Outcome::Absent => println!("no tool covers this need"), /// _ => {} @@ -56,7 +57,7 @@ use crate::rank::{Candidate, Vectors, comparable}; pub enum Outcome<'a> { /// One tool matched clearly enough to be used without asking. Bind(&'a ToolDescriptor), - /// One server publishes tools that are copies of each other. + /// One capability publishes tools that are copies of each other. Duplicate(CandidateGroup<'a>), /// Several tools match well enough that the margin could not separate them. Ambiguous(CandidateGroup<'a>), @@ -242,7 +243,7 @@ pub(crate) fn decide<'a>( let twins: Vec> = std::iter::once(leader) .chain(ranked[1..].iter().copied().filter(|candidate| { - candidate.tool.server() == leader.tool.server() + candidate.tool.id().capability() == leader.tool.id().capability() && vectors .similarity(leader.index, candidate.index) .is_some_and(|similarity| similarity >= config.duplicate_threshold()) diff --git a/crates/promptforge-tool-picker/src/policy/tests.rs b/crates/promptforge-tool-picker/src/policy/tests.rs index 01a6f7ca..e63f2b9c 100644 --- a/crates/promptforge-tool-picker/src/policy/tests.rs +++ b/crates/promptforge-tool-picker/src/policy/tests.rs @@ -40,12 +40,13 @@ fn rows(data: &[f32]) -> Vectors<'_> { Vectors::new(data, STRIDE) } -fn tool(server: &str, name: &str) -> ToolDescriptor { - ToolDescriptor::new(ToolId::new(server, name), "does a thing", json!({})) +fn tool(pack: &str, name: &str) -> ToolDescriptor { + let id = ToolId::parse(&format!("tests/{pack}/{name}")).expect("test ids are valid"); + ToolDescriptor::new(id, "does a thing", json!({})) } -fn hinted(server: &str, name: &str, annotations: ToolAnnotations) -> ToolDescriptor { - tool(server, name).with_annotations(annotations) +fn hinted(pack: &str, name: &str, annotations: ToolAnnotations) -> ToolDescriptor { + tool(pack, name).with_annotations(annotations) } fn ranking(scores: &[f32]) -> Vec { @@ -56,11 +57,11 @@ fn ranking(scores: &[f32]) -> Vec { .collect() } -fn one_server() -> Vec { +fn one_capability() -> Vec { vec![tool("files", "read_file"), tool("files", "load_file")] } -fn two_servers() -> Vec { +fn two_capabilities() -> Vec { vec![tool("files", "read_file"), tool("blobs", "read_file")] } @@ -83,7 +84,7 @@ fn group<'a>(tools: &'a [ToolDescriptor], indices: &[usize]) -> CandidateGroup<' #[test] fn nothing_above_the_floor_is_an_abstention() { - let tools = two_servers(); + let tools = two_capabilities(); let outcome = decide( &ranking(&[0.4, 0.3]), &tools, @@ -95,7 +96,7 @@ fn nothing_above_the_floor_is_an_abstention() { #[test] fn an_empty_ranking_and_out_of_range_candidates_abstain() { - let tools = two_servers(); + let tools = two_capabilities(); assert_eq!( decide(&[], &tools, rows(distinct(2)), &Config::default()), Outcome::Absent @@ -111,7 +112,7 @@ fn an_empty_ranking_and_out_of_range_candidates_abstain() { #[test] fn a_clear_leader_binds() { - let tools = two_servers(); + let tools = two_capabilities(); let outcome = decide( &ranking(&[0.95, 0.7]), &tools, @@ -122,8 +123,8 @@ fn a_clear_leader_binds() { } #[test] -fn twin_tools_on_one_server_are_a_duplicate() { - let tools = one_server(); +fn twin_tools_in_one_capability_are_a_duplicate() { + let tools = one_capability(); let outcome = decide( &ranking(&[0.99, 0.985]), &tools, @@ -134,8 +135,8 @@ fn twin_tools_on_one_server_are_a_duplicate() { } #[test] -fn twin_tools_across_servers_are_a_shortlist() { - let tools = two_servers(); +fn twin_tools_across_capabilities_are_a_shortlist() { + let tools = two_capabilities(); let outcome = decide( &ranking(&[0.99, 0.985]), &tools, @@ -149,7 +150,7 @@ fn twin_tools_across_servers_are_a_shortlist() { fn twins_are_measured_between_the_tools_not_between_their_scores() { let config = Config::default(); assert!(0.9 < config.duplicate_threshold()); - let tools = one_server(); + let tools = one_capability(); assert_eq!( decide(&ranking(&[0.9, 0.9]), &tools, rows(twinned(2)), &config), Outcome::Duplicate(group(&tools, &[0, 1])) @@ -159,7 +160,7 @@ fn twins_are_measured_between_the_tools_not_between_their_scores() { #[test] fn a_duplicate_is_reported_even_when_the_margin_would_separate_it() { let config = Config::default().with_margin(0.01).expect("valid margin"); - let tools = one_server(); + let tools = one_capability(); let outcome = decide(&ranking(&[0.995, 0.98]), &tools, rows(twinned(2)), &config); assert_eq!(outcome, Outcome::Duplicate(group(&tools, &[0, 1]))); } @@ -167,7 +168,7 @@ fn a_duplicate_is_reported_even_when_the_margin_would_separate_it() { #[test] fn a_score_exactly_at_the_floor_is_considered() { let config = exact_config(); - let tools = two_servers(); + let tools = two_capabilities(); assert_eq!( decide( &ranking(&[config.similarity_floor()]), @@ -191,7 +192,7 @@ fn a_score_exactly_at_the_floor_is_considered() { #[test] fn a_gap_exactly_equal_to_the_margin_binds() { let config = exact_config(); - let tools = two_servers(); + let tools = two_capabilities(); assert_eq!( decide(&ranking(&[0.875, 0.75]), &tools, rows(distinct(2)), &config), Outcome::Bind(&tools[0]) @@ -210,7 +211,7 @@ fn a_gap_exactly_equal_to_the_margin_binds() { #[test] fn a_pair_exactly_at_the_duplicate_threshold_is_a_twin() { let config = exact_config(); - let tools = one_server(); + let tools = one_capability(); let threshold = config.duplicate_threshold(); assert_eq!( decide( @@ -366,7 +367,7 @@ fn the_solo_candidate_rule_holds_at_its_boundaries() { .with_similarity_floor(0.8) .and_then(|config| config.with_solo_floor(0.5)) .expect("valid floors"); - let tools = two_servers(); + let tools = two_capabilities(); // One leader between the floors binds. assert_eq!( @@ -397,7 +398,7 @@ fn the_solo_candidate_rule_holds_at_its_boundaries() { #[test] fn shortlist_offers_only_above_floor_candidates() { let config = Config::default(); - let tools = two_servers(); + let tools = two_capabilities(); let listed = shortlist(&ranking(&[0.9, 0.4]), &tools, &config); assert_eq!(listed.len(), 1); assert_eq!(listed.first(), Some(&tools[0])); @@ -409,7 +410,7 @@ fn shortlist_returns_the_lone_solo_candidate_and_empties_on_two_peers() { .with_similarity_floor(0.8) .and_then(|config| config.with_solo_floor(0.5)) .expect("valid floors"); - let tools = two_servers(); + let tools = two_capabilities(); let solo = shortlist(&ranking(&[0.7]), &tools, &config); assert_eq!(solo.first(), Some(&tools[0]), "one leader between floors"); diff --git a/crates/promptforge-tool-picker/src/selected.rs b/crates/promptforge-tool-picker/src/selected.rs index 3f318466..f6214c60 100644 --- a/crates/promptforge-tool-picker/src/selected.rs +++ b/crates/promptforge-tool-picker/src/selected.rs @@ -174,18 +174,19 @@ mod tests { use crate::rank::Vectors; use serde_json::json; - fn tool(server: &str, name: &str) -> ToolDescriptor { - ToolDescriptor::new(ToolId::new(server, name), "does a thing", json!({})) + fn tool(pack: &str, name: &str) -> ToolDescriptor { + let id = ToolId::parse(&format!("tests/{pack}/{name}")).expect("test ids are valid"); + ToolDescriptor::new(id, "does a thing", json!({})) } #[test] fn an_absent_id_rejects_the_whole_selected_set_naming_the_first_missing() { let tools = vec![tool("files", "read"), tool("files", "write")]; - let missing = ToolId::new("missing", "tool"); + let missing = tool("missing", "tool").id().clone(); let ids = vec![ tools[0].id().clone(), missing.clone(), - ToolId::new("also", "missing"), + tool("also", "missing").id().clone(), ]; let error = near_duplicates(&tools, Vectors::new(&[1.0, 0.0, 1.0, 0.0], 2), 0.9, &ids) .expect_err("an absent identity rejects the set"); @@ -193,7 +194,7 @@ mod tests { } #[test] - fn repeated_ids_are_idempotent_and_cross_server_pairs_follow_catalog_order() { + fn repeated_ids_are_idempotent_and_cross_capability_pairs_follow_catalog_order() { let tools = vec![ tool("first", "read"), tool("second", "read"), diff --git a/crates/promptforge-tool-picker/tests/fixtures/mixed-servers.json b/crates/promptforge-tool-picker/tests/fixtures/mixed-servers.json index 2c45eb20..6a95c5f5 100644 --- a/crates/promptforge-tool-picker/tests/fixtures/mixed-servers.json +++ b/crates/promptforge-tool-picker/tests/fixtures/mixed-servers.json @@ -1,7 +1,6 @@ [ { - "server": "weather", - "name": "get_forecast", + "id": "tests/weather/get_forecast", "description": "Get the weather forecast for a city over the next several days", "inputSchema": { "type": "object", @@ -13,8 +12,7 @@ "annotations": { "readOnlyHint": true } }, { - "server": "files", - "name": "read_file", + "id": "tests/files/read_file", "description": "Read the contents of a file from the local disk", "inputSchema": { "type": "object", @@ -25,8 +23,7 @@ "annotations": { "readOnlyHint": true } }, { - "server": "blobs", - "name": "read_text_file", + "id": "tests/blobs/read_text_file", "description": "Read the contents of a file from the local disk", "inputSchema": { "type": "object", @@ -37,8 +34,7 @@ "annotations": { "readOnlyHint": true } }, { - "server": "calendar", - "name": "create_event", + "id": "tests/calendar/create_event", "description": "Create a new calendar event on the authenticated user's primary calendar, with a title, a start time, an end time, and an optional list of attendees who will each be sent an invitation", "inputSchema": { "type": "object", @@ -51,8 +47,7 @@ } }, { - "server": "calendar", - "name": "add_event", + "id": "tests/calendar/add_event", "description": "Create a new calendar event on the authenticated user's primary calendar, with a title, a start time, an end time, and an optional list of attendees who will each be sent an invitation", "inputSchema": { "type": "object", diff --git a/crates/promptforge-tool-picker/tests/it/behavior.rs b/crates/promptforge-tool-picker/tests/it/behavior.rs index c35a67b8..7ad18128 100644 --- a/crates/promptforge-tool-picker/tests/it/behavior.rs +++ b/crates/promptforge-tool-picker/tests/it/behavior.rs @@ -11,12 +11,20 @@ use promptforge_tool_picker::{Catalog, Config, Outcome, ToolId, ToolPicker}; /// The fixture catalog, as it is committed. const MIXED_SERVERS: &str = include_str!("../fixtures/mixed-servers.json"); -/// A need only `weather/get_forecast` covers. +/// Parses a fixture identity: every fixture id is a valid global tool name. +fn tid(id: &str) -> ToolId { + match ToolId::parse(id) { + Ok(id) => id, + Err(error) => panic!("the fixture ids are valid global names: {error}"), + } +} + +/// A need only `tests/weather/get_forecast` covers. const BIND_NEED: &str = "get the weather forecast for a city"; -/// A need both of one server's copy-pasted calendar tools cover. +/// A need both of one capability's copy-pasted calendar tools cover. const DUPLICATE_NEED: &str = "create a new calendar event with a title, a start time and an end time"; -/// A need two servers cover equally well. +/// A need two capabilities cover equally well. const AMBIGUOUS_NEED: &str = "read the contents of a file from the local disk"; /// A need no tool in the catalog covers at all. const ABSENT_NEED: &str = "translate this paragraph into Japanese"; @@ -45,19 +53,19 @@ fn picker() -> &'static ToolPicker { #[test] fn a_need_only_one_tool_covers_binds_that_tool() { match picker().resolve(BIND_NEED).expect("resolve") { - Outcome::Bind(tool) => assert_eq!(tool.id(), &ToolId::new("weather", "get_forecast")), + Outcome::Bind(tool) => assert_eq!(tool.id(), &tid("tests/weather/get_forecast")), other => panic!("a need with one plain answer must bind, got {other:?}"), } } #[test] -fn one_servers_copy_pasted_pair_is_reported_as_a_duplicate() { +fn one_capabilitys_copy_pasted_pair_is_reported_as_a_duplicate() { let outcome = picker().resolve(DUPLICATE_NEED).expect("resolve"); let Outcome::Duplicate(group) = &outcome else { - panic!("one server's two names for one tool must be a duplicate, got {outcome:?}"); + panic!("one capability's two names for one tool must be a duplicate, got {outcome:?}"); }; - assert_eq!(group.first().id(), &ToolId::new("calendar", "create_event")); - assert_eq!(group.second().id(), &ToolId::new("calendar", "add_event")); + assert_eq!(group.first().id(), &tid("tests/calendar/create_event")); + assert_eq!(group.second().id(), &tid("tests/calendar/add_event")); } #[test] @@ -65,8 +73,8 @@ fn a_duplicate_is_decided_between_the_tools_and_not_between_their_scores() { let picker = picker(); let calendar = picker .near_duplicates(&[ - ToolId::new("calendar", "create_event"), - ToolId::new("calendar", "add_event"), + tid("tests/calendar/create_event"), + tid("tests/calendar/add_event"), ]) .expect("selected analysis"); assert_eq!( @@ -77,8 +85,8 @@ fn a_duplicate_is_decided_between_the_tools_and_not_between_their_scores() { let files = picker .near_duplicates(&[ - ToolId::new("files", "read_file"), - ToolId::new("blobs", "read_text_file"), + tid("tests/files/read_file"), + tid("tests/blobs/read_text_file"), ]) .expect("selected analysis"); assert!( @@ -88,14 +96,17 @@ fn a_duplicate_is_decided_between_the_tools_and_not_between_their_scores() { } #[test] -fn two_servers_publishing_one_capability_are_ambiguous() { +fn two_capabilities_publishing_equivalent_tools_are_ambiguous() { let outcome = picker().resolve(AMBIGUOUS_NEED).expect("resolve"); let Outcome::Ambiguous(group) = &outcome else { panic!("a near-tie the margin cannot separate must be a shortlist, got {outcome:?}"); }; - assert_eq!(group.first().id(), &ToolId::new("files", "read_file")); - assert_eq!(group.second().id(), &ToolId::new("blobs", "read_text_file")); - assert_ne!(group.first().server(), group.second().server()); + assert_eq!(group.first().id(), &tid("tests/files/read_file")); + assert_eq!(group.second().id(), &tid("tests/blobs/read_text_file")); + assert_ne!( + group.first().id().capability(), + group.second().id().capability() + ); } #[test] @@ -133,7 +144,7 @@ fn a_shortlist_offers_exactly_the_tools_the_decision_weighed() { assert_eq!(bind.len(), 1); assert_eq!( bind.first().map(|tool| tool.id().clone()), - Some(ToolId::new("weather", "get_forecast")) + Some(tid("tests/weather/get_forecast")) ); } diff --git a/crates/promptforge-tool-picker/tests/it/public_api.rs b/crates/promptforge-tool-picker/tests/it/public_api.rs index 3739919e..4b388812 100644 --- a/crates/promptforge-tool-picker/tests/it/public_api.rs +++ b/crates/promptforge-tool-picker/tests/it/public_api.rs @@ -20,16 +20,24 @@ fn model() -> &'static Model { }) } +/// Parses a test identity: every test id is a valid global tool name. +fn tid(pack: &str, name: &str) -> ToolId { + match ToolId::parse(&format!("tests/{pack}/{name}")) { + Ok(id) => id, + Err(error) => panic!("test ids are valid global names: {error}"), + } +} + /// Two plainly unrelated tools: enough to bind one and to miss both. fn catalog() -> Catalog { Catalog::new(vec![ ToolDescriptor::new( - ToolId::new("files", "read_file"), + tid("files", "read_file"), "Read a file from disk", json!({"properties": {"path": {"type": "string"}}}), ), ToolDescriptor::new( - ToolId::new("net", "fetch_url"), + tid("net", "fetch_url"), "Fetch a web page over HTTP", json!({"properties": {"url": {"type": "string"}}}), ), @@ -57,7 +65,7 @@ fn a_caller_builds_resolves_and_shortlists_through_the_public_api() { assert_eq!(picker.iter().count(), 2); match picker.resolve("read a file from disk").expect("resolve") { - Outcome::Bind(tool) => assert_eq!(tool.id(), &ToolId::new("files", "read_file")), + Outcome::Bind(tool) => assert_eq!(tool.id(), &tid("files", "read_file")), outcome => panic!("expected a binding, got {outcome:?}"), } @@ -73,15 +81,12 @@ fn a_caller_builds_resolves_and_shortlists_through_the_public_api() { .expect("shortlist"); assert_eq!( listed.first().map(ToolDescriptor::id), - Some(&ToolId::new("net", "fetch_url")) + Some(&tid("net", "fetch_url")) ); assert!(listed.len() <= 2); let pairs = picker - .near_duplicates(&[ - ToolId::new("net", "fetch_url"), - ToolId::new("files", "read_file"), - ]) + .near_duplicates(&[tid("net", "fetch_url"), tid("files", "read_file")]) .expect("selected analysis"); assert!(pairs.is_empty()); } @@ -90,9 +95,9 @@ fn a_caller_builds_resolves_and_shortlists_through_the_public_api() { fn a_selection_error_names_the_first_missing_identity() { let picker = ToolPicker::build_with_model(model(), catalog(), Config::default(), None) .expect("index the catalog"); - let missing = ToolId::new("missing", "tool"); + let missing = tid("missing", "tool"); let error = picker - .near_duplicates(&[ToolId::new("files", "read_file"), missing.clone()]) + .near_duplicates(&[tid("files", "read_file"), missing.clone()]) .expect_err("an absent identity rejects the selected set"); assert_eq!(error.missing_id(), &missing); } @@ -102,7 +107,7 @@ fn one_model_builds_a_picker_per_catalog_and_rebuild_reuses_it() { let files = ToolPicker::build_with_model(model(), catalog(), Config::default(), None) .expect("index the catalog"); let weather = Catalog::new(vec![ToolDescriptor::new( - ToolId::new("weather", "get_forecast"), + tid("weather", "get_forecast"), "Get the weather forecast for a city", json!({"properties": {"city": {"type": "string"}}}), )]); @@ -113,7 +118,7 @@ fn one_model_builds_a_picker_per_catalog_and_rebuild_reuses_it() { .resolve("get the weather forecast for a city") .expect("resolve") { - Outcome::Bind(tool) => assert_eq!(tool.id(), &ToolId::new("weather", "get_forecast")), + Outcome::Bind(tool) => assert_eq!(tool.id(), &tid("weather", "get_forecast")), outcome => panic!("expected a binding, got {outcome:?}"), } assert_eq!( diff --git a/crates/shared-promptforge-api/src/models.rs b/crates/shared-promptforge-api/src/models.rs index 5f4435d5..071427b2 100644 --- a/crates/shared-promptforge-api/src/models.rs +++ b/crates/shared-promptforge-api/src/models.rs @@ -77,17 +77,11 @@ impl ModelId { } } - /// 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. + /// UTF-8 encoding a byte-range scan would miss. fn validate(field: &'static str, value: &str) -> std::result::Result<(), ModelIdError> { if value.is_empty() { return Err(ModelIdError { @@ -95,10 +89,7 @@ impl ModelId { reason: "must not be empty", }); } - if value - .chars() - .any(|c| c.is_control() || c == Self::PICKER_SEPARATOR) - { + if value.chars().any(char::is_control) { return Err(ModelIdError { field, reason: "must not contain a control character", @@ -342,8 +333,8 @@ 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. + fn rejects_c0_c1_and_other_control_scalars() { + // The C0 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). diff --git a/crates/shared-promptforge-api/src/tools/ids.rs b/crates/shared-promptforge-api/src/tools/ids.rs index 272888e1..7069d563 100644 --- a/crates/shared-promptforge-api/src/tools/ids.rs +++ b/crates/shared-promptforge-api/src/tools/ids.rs @@ -103,6 +103,30 @@ impl ToolId { } } +impl std::fmt::Display for ToolId { + /// The canonical `namespace/pack/name` string form. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl serde::Serialize for ToolId { + /// Serializes the identity as its one `namespace/pack/name` string. + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for ToolId { + /// Deserializes the identity from its string form, validating it as a + /// 3-segment global name: an invalid string is a data error, never a + /// silently accepted identity. + fn deserialize>(deserializer: D) -> Result { + let text = ::deserialize(deserializer)?; + ToolId::parse(&text).map_err(serde::de::Error::custom) + } +} + /// A stable, matchable classification of a [`ToolIdError`]. /// /// Every public error exposes a `kind()` classifier so callers can branch on the diff --git a/crates/shared-promptforge-api/src/tools/tests.rs b/crates/shared-promptforge-api/src/tools/tests.rs index 456879d7..d2d96812 100644 --- a/crates/shared-promptforge-api/src/tools/tests.rs +++ b/crates/shared-promptforge-api/src/tools/tests.rs @@ -324,6 +324,23 @@ fn the_migrated_built_in_ids_parse() { assert!(ToolId::parse("promptforge/web/search").is_ok()); } +#[test] +fn a_tool_id_serializes_as_its_global_name_string() { + let id = ToolId::parse("promptforge/web/fetch").expect("a valid tool id"); + assert_eq!( + serde_json::to_string(&id).expect("serialize"), + "\"promptforge/web/fetch\"" + ); + let parsed: ToolId = serde_json::from_str("\"promptforge/web/fetch\"").expect("deserialize"); + assert_eq!(parsed, id); +} + +#[test] +fn deserializing_an_invalid_tool_id_is_a_data_error() { + assert!(serde_json::from_str::("\"promptforge/web_fetch\"").is_err()); + assert!(serde_json::from_str::("\"promptforge/web/fetch/extra\"").is_err()); +} + #[test] fn catalog_rejects_illegal_wire_name() { struct BadWire; diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 65ceadd6..063e9e7a 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -886,7 +886,7 @@ New `names` module in `shared-promptforge-api`: `GlobalName` (private segments, -### Step 3: Picker ToolId migration +### Step 3: Picker ToolId migration [completed] - Component: global-names From 8236e17d6da7e1da425aed8558cf2bd5c734e390 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 19:52:10 -0700 Subject: [PATCH 04/30] Consolidate the run interface into Environment/RunContext Merge the borrowed resolution context and the owned run configuration into a deployment environment built once per host plus a per-run context, and make running a prompt return an outcome enum instead of a thrown error. The environment offers a zero-burden run path that installs the live resolution inputs and a default client; a hand-built context runs capability-free. Cancellation is now its own outcome rather than an error kind. Every call site, test, bench, and doc example migrates with no behavior change. - `Environment` is the new deployment-scoped type: it absorbs the retired resolution context's picker, model catalog, and tool catalog as interim fields, and `Environment::run` installs them on the context before driving the run. - `RunContext` replaces `RunConfig`: the execution id is renamed `name`, and the context gains `start_time`, `depth`, and a crate-internal `resolution` slot the environment fills. - `RunResult` reports the run's outcome as `Ok(String)`, `Cancelled`, or `Failure(RunError)`, so domain outcomes are values and cancellation leaves the error channel. - `RunState` is the new name of the crate-internal ambient context, and `ResolutionContext` drops to `pub(crate)` as the borrowed shape the live H1 pass still consumes. - `run` treats a context without installed resolution inputs as capability-free: no picker, empty catalogs. - `Environment::run` defaults a client-less context to the environment's client, covered by the new multi-thread test that asserts the completion reaches that client. - `base_vfs` and `max_depth` are carried but not consulted, and `RunContext.depth` is always 0. - `RunErrorKind::RequirementsUnmet` is added as a classification only; nothing raises it yet. Design: new facade @ crates/promptforge-api/src/execute/environment.rs::Environment boundary: pub Design: new parameter-object @ crates/promptforge-api/src/execute.rs::run deps: Prompt,RunContext,str Design: new surface-growth @ crates/promptforge-api/src/lib.rs boundary: pub Deferred: Environment.base_vfs and Environment.max_depth are carried inert until the prepare pass builds the per-run router and the nesting guard Deferred: the Environment registry slot lands with the prepare pass Deferred: RunContext.depth stays 0 until the sub-run adapter lands with the prompt-pack Deferred: RunErrorKind::RequirementsUnmet has no raising behavior yet Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/README.md | 25 +- crates/promptforge-api/benches/models_loop.rs | 52 ++-- crates/promptforge-api/src/client.rs | 2 +- crates/promptforge-api/src/debug.rs | 2 +- crates/promptforge-api/src/execute.rs | 130 ++++++---- crates/promptforge-api/src/execute/config.rs | 126 +++++++--- crates/promptforge-api/src/execute/context.rs | 36 +-- .../src/execute/environment.rs | 147 +++++++++++ crates/promptforge-api/src/execute/error.rs | 2 + crates/promptforge-api/src/execute/gateway.rs | 11 +- .../promptforge-api/src/execute/scheduler.rs | 16 +- .../src/execute/section_context.rs | 18 +- .../src/execute/tests/exec_flow.rs | 78 +++--- .../src/execute/tests/input.rs | 22 +- .../src/execute/tests/live_infer.rs | 233 +++++++++++------- .../promptforge-api/src/execute/tests/mod.rs | 86 ++++--- .../src/execute/tests/models_loop.rs | 6 +- .../src/execute/tests/observations.rs | 5 +- .../src/execute/tests/scheduler.rs | 38 +-- crates/promptforge-api/src/lib.rs | 41 ++- crates/promptforge-api/tests/suite/support.rs | 26 +- crates/workshop-server/tests/it/chat_gate.rs | 26 +- .../src/agents/supervisor/effects.rs | 29 +-- crates/workshop-sessions/src/agents/tests.rs | 22 +- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 25 files changed, 716 insertions(+), 465 deletions(-) create mode 100644 crates/promptforge-api/src/execute/environment.rs diff --git a/crates/promptforge-api/README.md b/crates/promptforge-api/README.md index 4c4bfc36..edf2f358 100644 --- a/crates/promptforge-api/README.md +++ b/crates/promptforge-api/README.md @@ -15,26 +15,19 @@ shared-promptforge-api = "0.1" ``` ```rust -use promptforge_api::{Prompt, ResolutionContext, RunConfig, run}; -use shared_promptforge_api::models::ModelCatalog; +use promptforge_api::{Environment, Prompt, RunContext, RunResult}; 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())?; - // 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 result = run( - &prompt, - "", - ResolutionContext::new(None, &models, &tools), - RunConfig::new("readme"), - ) - .await?; - Ok(result) + // Capability-free agents use the default environment (no picker, empty + // catalogs); the store handle defaults to a stock in-memory mount. + let env = Environment::new(); + match env.run(&prompt, "", RunContext::new("readme")).await { + RunResult::Ok(text) => Ok(text), + RunResult::Cancelled => Err("the run was cancelled".into()), + RunResult::Failure(error) => Err(error.into()), + } } ``` diff --git a/crates/promptforge-api/benches/models_loop.rs b/crates/promptforge-api/benches/models_loop.rs index eb46212c..5a26b34c 100644 --- a/crates/promptforge-api/benches/models_loop.rs +++ b/crates/promptforge-api/benches/models_loop.rs @@ -26,7 +26,7 @@ use axum::response::IntoResponse; use axum::routing::post; use criterion::{Criterion, criterion_group, criterion_main}; use promptforge_api::client::{GatewayClient, GatewayEndpoint, SecretString}; -use promptforge_api::{Prompt, ResolutionContext, RunConfig, run}; +use promptforge_api::{Environment, Prompt, RunContext, RunResult}; use promptforge_tool_picker::{Catalog, Config, ToolPicker}; use shared_promptforge_api::models::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; use shared_promptforge_api::observe::NullObserver; @@ -141,14 +141,13 @@ fn parse_loop_prompt() -> Prompt { .expect("the bench prompt parses") } -/// The resolution context every bench run shares: an empty tool picker and -/// no tools, so the loop is one terminal turn. -fn resolution<'a>( - picker: &'a ToolPicker, - models: &'a ModelCatalog, - tools: &'a ToolCatalog, -) -> ResolutionContext<'a> { - ResolutionContext::new(Some(picker), models, tools) +/// The environment every bench run shares: an empty tool picker and no +/// tools, so the loop is one terminal turn. +fn bench_env(picker: ToolPicker, models: ModelCatalog) -> Environment { + Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::new(&[]).expect("the empty bench tool catalog builds")) } /// One `models.loop` turn end to end: parse is excluded, so the measurement @@ -164,20 +163,22 @@ fn models_loop(c: &mut Criterion) { let prompt = parse_loop_prompt(); let picker = ToolPicker::build(Catalog::new(Vec::new()), Config::default()) .expect("the empty bench picker builds"); - let models = bench_catalog(131_072); - let tools = ToolCatalog::new(&[]).expect("the empty bench tool catalog builds"); + let env = bench_env(picker, bench_catalog(131_072)); c.bench_function("models_loop", |b| { b.iter(|| { - runtime - .block_on(run( + let result = runtime.block_on( + env.run( &prompt, "", - resolution(&picker, &models, &tools), - RunConfig::new(EXECUTION) + RunContext::new(EXECUTION) .observer(Arc::new(NullObserver::default())) .client(gateway.client()), - )) - .expect("the loop bench run succeeds"); + ), + ); + assert!( + matches!(result, RunResult::Ok(_)), + "the loop bench run succeeds: {result:?}" + ); }); }); assert!( @@ -199,20 +200,21 @@ fn compactors_fail(c: &mut Criterion) { let prompt = parse_loop_prompt(); let picker = ToolPicker::build(Catalog::new(Vec::new()), Config::default()) .expect("the empty bench picker builds"); - let models = bench_catalog(1); - let tools = ToolCatalog::new(&[]).expect("the empty bench tool catalog builds"); + let env = bench_env(picker, bench_catalog(1)); c.bench_function("compactors_fail", |b| { b.iter(|| { - let error = runtime - .block_on(run( + let result = runtime.block_on( + env.run( &prompt, "", - resolution(&picker, &models, &tools), - RunConfig::new(EXECUTION) + RunContext::new(EXECUTION) .observer(Arc::new(NullObserver::default())) .client(gateway.client()), - )) - .expect_err("a one-token window must exhaust at the precheck"); + ), + ); + let RunResult::Failure(error) = result else { + panic!("a one-token window must exhaust at the precheck"); + }; assert_eq!( error.kind(), promptforge_api::RunErrorKind::ContextExhausted, diff --git a/crates/promptforge-api/src/client.rs b/crates/promptforge-api/src/client.rs index be26baa7..40c3c49b 100644 --- a/crates/promptforge-api/src/client.rs +++ b/crates/promptforge-api/src/client.rs @@ -12,7 +12,7 @@ //! //! The implementation lives in the `promptforge-model-client` crate and is //! re-exported here: hosts pass a [`GatewayClient`] to -//! [`RunConfig::client`](crate::RunConfig) and classify its failures through +//! [`RunContext::client`](crate::RunContext) and classify its failures through //! [`CompletionError`]. pub use promptforge_model_client::client::{GatewayClient, GatewayEndpoint, SecretString}; diff --git a/crates/promptforge-api/src/debug.rs b/crates/promptforge-api/src/debug.rs index 373fb311..c1569df8 100644 --- a/crates/promptforge-api/src/debug.rs +++ b/crates/promptforge-api/src/debug.rs @@ -3,7 +3,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 //! [`shared_promptforge_api::observe::Observer`]: observations stay payload-free, and production -//! hosts leave [`crate::execute::RunConfig::debug`] unset so they pay +//! hosts leave [`crate::execute::RunContext::debug`] unset so they pay //! nothing for this path. use serde_json::Value; diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index 8375191d..33b4b536 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -16,13 +16,13 @@ //! the same rules, and the parent walk resumes after the jumper when that //! level exhausts. //! -//! One run-scoped store handle travels with the run's [`RunConfig`] (the +//! One run-scoped store handle travels with the run's [`RunContext`] (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. //! -//! A run reports itself as it goes: the [`RunConfig`] observer receives a +//! A run reports itself as it goes: the [`RunContext`] observer receives a //! `(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 @@ -57,8 +57,10 @@ //! //! The orchestration boundary ([`run`]) lives here; the rest is split into //! focused private children: `error` (the public [`RunError`]), `config` -//! (`RunConfig`/`RunLimits`), `context` (the ambient `RunContext` run -//! state), `gateway` (client acquisition and [`ResolutionContext`]), +//! ([`RunContext`]/[`RunLimits`]), `environment` (the public +//! [`Environment`]), `context` (the ambient `RunState` run +//! state), `gateway` (client acquisition and the live H1 resolution +//! inputs), //! `tools` (the nested-inference round), //! `section_vm` (the section VM setup half shared by the walk and //! the fanout arm), `section_context` (the per-section `SectionContext` @@ -75,6 +77,7 @@ mod config; mod context; mod engine; +mod environment; mod error; mod gateway; pub(crate) mod protocol; @@ -87,11 +90,12 @@ mod tool_loop; mod tools; // Public API surface. -pub use config::{RunConfig, RunLimits}; +pub use config::{RunContext, RunLimits}; +pub use environment::Environment; pub use error::{RunError, RunErrorKind}; -pub use gateway::ResolutionContext; +pub(crate) use gateway::ResolutionContext; -use context::RunContext; +use context::RunState; use scheduler::Scheduler; use crate::Error; @@ -100,15 +104,37 @@ use crate::observe::detail; use crate::parser::{ParseErrorKind, Prompt}; use crate::store::VfsRef; +/// What the run produced. Domain outcomes (including "the prompt +/// declined") are values, not thrown errors: the variant is for code, the +/// payload is for humans and models. +#[derive(Debug)] +pub enum RunResult { + /// The run completed with its final text. Mirrors `Result` vocabulary, + /// so patterns need `RunResult::Ok` qualification wherever `Result` is + /// also in scope. + Ok(String), + /// The host cancelled the run. + Cancelled, + /// The run failed; the typed error classifies the failure. + Failure(RunError), +} + /// Executes a parsed prompt and returns its final text. /// /// H1 Lua and prose blocks run once in source order with full host access; /// capability calls resolve when executed. If H1 does not return, the H2 section /// walk runs and its final text is returned. /// -/// # Errors -/// Returns a [`RunError`] whose [`kind`](RunError::kind) classifies the failure -/// by condition: +/// The free `run` receives an already-prepared [`RunContext`] and has +/// nothing to prepare from: a context that never passed through +/// [`Environment::run`] runs capability-free (no picker, empty catalogs). +/// Hosts normally go through [`Environment::run`], the zero-burden path. +/// +/// # Outcomes +/// - [`RunResult::Ok`] - the run completed with its final text. +/// - [`RunResult::Cancelled`] - the host cancelled the run. +/// - [`RunResult::Failure`] - the run failed; the [`RunError`]'s +/// [`kind`](RunError::kind) classifies the failure by condition: /// - [`RunErrorKind::Parse`] - a prompt/frontmatter or compiled Lua region was /// invalid. /// - [`RunErrorKind::Version`] - the prompt declared an unsupported @@ -131,19 +157,20 @@ use crate::store::VfsRef; /// - [`RunErrorKind::Store`] - a run-scoped store operation failed. /// - [`RunErrorKind::Determinism`] - two live execution identities claimed /// one store path; the run terminated on the spot, uncatchably from Lua. -/// - [`RunErrorKind::Cancelled`] - the host cancelled the run. +/// - [`RunErrorKind::Cancelled`] - the host cancelled the run (mid-run +/// classification only; the interface reports [`RunResult::Cancelled`]). /// - [`RunErrorKind::Internal`] - an internal invariant failed. +/// - [`RunErrorKind::RequirementsUnmet`] - an H1 assertion or model +/// requirement the environment cannot satisfy. /// /// # Examples /// A no-network prompt whose walk makes a nested host call: `call` is a /// 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_api::execute::{run, RunConfig, ResolutionContext}; +/// use promptforge_api::execute::{RunContext, RunResult, run}; /// 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,17 +181,12 @@ use crate::store::VfsRef; /// "```lua\nreturn 'hello'\n```\n", /// ); /// let prompt = Prompt::parse(source, "doc-example", &NullObserver::default())?; -/// let models = ModelCatalog::empty(); -/// let tools = ToolCatalog::new(&[])?; -/// /// let runtime = tokio::runtime::Builder::new_current_thread().build()?; -/// let output = runtime.block_on(run( -/// &prompt, -/// "", -/// ResolutionContext::new(None, &models, &tools), -/// RunConfig::new("doc-example"), -/// ))?; -/// assert_eq!(output, "hello"); +/// let output = runtime.block_on(run(&prompt, "", RunContext::new("doc-example"))); +/// let RunResult::Ok(text) = output else { +/// panic!("the doc example run succeeds: {output:?}"); +/// }; +/// assert_eq!(text, "hello"); /// # Ok::<(), Box>(()) /// ``` /// @@ -177,17 +199,14 @@ use crate::store::VfsRef; /// from interleaving chains at I/O points, not from threads; on a /// multi-thread runtime only the leaf I/O waits, which never touch Lua or /// scheduler state, may run on other workers. -pub async fn run( - prompt: &Prompt, - args: &str, - resolution: ResolutionContext<'_>, - config: RunConfig, -) -> std::result::Result { +pub async fn run(prompt: &Prompt, args: &str, ctx: RunContext) -> RunResult { match prompt.frontmatter().promptforge() { Some(0) => {} - Some(other) => return Err(RunError::from(Error::UnsupportedVersion(other))), + Some(other) => { + return RunResult::Failure(RunError::from(Error::UnsupportedVersion(other))); + } None => { - return Err(RunError::from(Error::parse( + return RunResult::Failure(RunError::from(Error::parse( ParseErrorKind::Structure, "not a promptforge prompt: no promptforge version", ))); @@ -199,45 +218,56 @@ pub async fn run( // sequence carries no `Option` branch. let shared = match prompt.replay() { Some(program) => program.clone(), - None => { - crate::lua::LuaProgram::empty().map_err(|error| RunError::from(Error::from(error)))? - } + None => match crate::lua::LuaProgram::empty() { + Ok(program) => program, + Err(error) => return RunResult::Failure(RunError::from(Error::from(error))), + }, }; // The stock handle carries the store mount; a hand-built router lacking // 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 mut config = config; - match store_mount_present(&config.vfs) { + let mut ctx = ctx; + match store_mount_present(&ctx.vfs) { Ok(true) => {} Ok(false) => { - config.vfs = config.vfs.overlay( + ctx.vfs = ctx.vfs.overlay( promptforge_vfs::STORE_MOUNT, shared_vfs::MemoryBackend::new(), ); } - Err(error) => return Err(RunError::from(Error::Store(error))), + Err(error) => return RunResult::Failure(RunError::from(Error::Store(error))), } - let ctx = RunContext::new(prompt, args, &config.vfs, shared, &config); + let state = RunState::new(prompt, args, &ctx.vfs, shared, &ctx); - let RunConfig { - execution, + let RunContext { + name, observer, client, cancel, limits, + resolution, .. - } = config; + } = ctx; let client = client.map(|client| client.with_request_limits(limits.timeout(), limits.response_bytes())); - observer.observe(&execution, prompt.title(), detail::RUN_STARTED); + observer.observe(&name, prompt.title(), detail::RUN_STARTED); + + // A context that never passed through `Environment::run` carries no + // resolution inputs and runs capability-free: no picker, empty catalogs. + let resolution = resolution.unwrap_or_default(); + let live = ResolutionContext::new( + resolution.picker.as_deref(), + &resolution.models, + &resolution.tools, + ); // Boxed: the driver future carries the whole scheduler step machinery, // and `run`'s own future must stay small for its callers (the // workspace's large-futures lint gates every one of them). let run_body = Box::pin(async { - Scheduler::new(&ctx, client) - .with_live_h1(resolution) + Scheduler::new(&state, client) + .with_live_h1(live) .drive() .await }); @@ -248,7 +278,7 @@ pub async fn run( let result = cancel::maybe_scope(cancel, run_body).await; observer.observe( - &execution, + &name, prompt.title(), if result.is_ok() { detail::RUN_SUCCEEDED @@ -256,7 +286,11 @@ pub async fn run( detail::RUN_FAILED }, ); - result.map_err(RunError::from) + match result { + Ok(text) => RunResult::Ok(text), + Err(Error::Interrupted) => RunResult::Cancelled, + Err(error) => RunResult::Failure(RunError::from(error)), + } } /// Whether the handle already serves the store mount. The probe stats the diff --git a/crates/promptforge-api/src/execute/config.rs b/crates/promptforge-api/src/execute/config.rs index ab90deb0..c3d31d79 100644 --- a/crates/promptforge-api/src/execute/config.rs +++ b/crates/promptforge-api/src/execute/config.rs @@ -1,16 +1,20 @@ -//! Run configuration and resource limits: [`RunConfig`] and [`RunLimits`]. +//! Per-run context and resource limits: [`RunContext`] and [`RunLimits`]. use std::fmt; use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime}; + +use promptforge_tool_picker::ToolPicker; use crate::cancel::CancelHandle; use crate::client::{GatewayClient, StreamDelta}; use crate::debug::DebugCapture; use crate::input::InputBroker; +use crate::model::ModelCatalog; use crate::observe::{NullObserver, Observer}; use crate::store::VfsRef; +use crate::tools::ToolCatalog; /// Generates one `nz_*` constructor per `NonZero*` type: a `const fn` /// building the wrapper from a compile-time-known non-zero value. @@ -168,24 +172,47 @@ impl Default for RunLimits { } } -/// 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. +/// The live resolution inputs [`Environment::run`](super::Environment::run) +/// installs on a context before the free [`run`](super::run) drives it: the +/// interim stand-in for the prepare pass, absorbing what the retired +/// borrowed resolution context carried (a picker, a model catalog, a tool +/// catalog). A context without one runs capability-free. +#[derive(Clone, Default)] +pub(crate) struct RunResolution { + /// Semantic picker behind executed H1 binds; `None` fails a bind as a + /// binding error naming the missing picker. + pub(crate) picker: Option>, + /// Live model catalog behind executed H1 model calls. + pub(crate) models: ModelCatalog, + /// Tool catalog behind executed H1 `tools.bind` calls. + pub(crate) tools: ToolCatalog, +} + +/// One run. Created by the host from the +/// [`Environment`](super::Environment) carrying the per-run inputs, +/// enriched at prepare, owned by the executor during +/// [`run`](super::run). Never shared between runs. /// -/// `RunConfig` is owned (no borrows), so its observer and debug sinks reach the -/// nested `models.infer` path that a borrowed option could not. +/// `RunContext` 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_api::execute::{RunConfig, RunLimits}; +/// use promptforge_api::execute::{RunContext, RunLimits}; /// -/// let config = RunConfig::new("example-run").limits(RunLimits::new()); -/// assert_eq!(config.execution(), "example-run"); +/// let ctx = RunContext::new("example-run").limits(RunLimits::new()); +/// assert_eq!(ctx.name(), "example-run"); /// ``` #[non_exhaustive] -pub struct RunConfig { - pub(crate) execution: String, +pub struct RunContext { + /// Run identity, carried on every report and event. + pub(crate) name: String, + /// When the context was created. + pub(crate) start_time: SystemTime, + /// Model-orchestrated prompt-tool nesting depth: 0 for a root run. + /// Always 0 today - the sub-run adapter that increments it lands with + /// the deferred prompt-pack. + pub(crate) depth: u32, pub(crate) observer: Arc, pub(crate) debug: Option>, pub(crate) client: Option, @@ -195,17 +222,23 @@ pub struct RunConfig { pub(crate) ui: Option serde_json::Value + Send + Sync>>, pub(crate) on_delta: Option>, pub(crate) vfs: VfsRef, + /// The resolution inputs [`Environment::run`](super::Environment::run) + /// installs; `None` on a caller-built context, which the free + /// [`run`](super::run) treats as capability-free. + pub(crate) resolution: Option, } -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, default [`RunLimits`], and the stock store handle +impl RunContext { + /// Builds a context for the run `name` with default observer, no client, + /// no capture, no cancellation, no input broker, no `ui` provider, no + /// delta callback, default [`RunLimits`], and the stock store handle /// (`promptforge_vfs::empty()`). #[must_use] - pub fn new(execution: impl Into) -> RunConfig { - RunConfig { - execution: execution.into(), + pub fn new(name: impl Into) -> RunContext { + RunContext { + name: name.into(), + start_time: SystemTime::now(), + depth: 0, observer: Arc::new(NullObserver::default()), debug: None, client: None, @@ -215,41 +248,43 @@ impl RunConfig { ui: None, on_delta: None, vfs: promptforge_vfs::empty(), + resolution: None, } } /// Sets the progress observer, retained for the whole run and its infer hook. #[must_use] - pub fn observer(mut self, observer: Arc) -> RunConfig { + pub fn observer(mut self, observer: Arc) -> RunContext { self.observer = observer; self } /// Sets the opt-in raw request/response capture sink. #[must_use] - pub fn debug(mut self, debug: Arc) -> RunConfig { + pub fn debug(mut self, debug: Arc) -> RunContext { self.debug = Some(debug); self } - /// Sets the gateway client; `None` builds one from the environment on first - /// use. + /// Sets the gateway client, overriding the + /// [`Environment`](super::Environment)'s; `None` builds one from the + /// process environment on first use. #[must_use] - pub fn client(mut self, client: GatewayClient) -> RunConfig { + pub fn client(mut self, client: GatewayClient) -> RunContext { self.client = Some(client); self } /// Sets the explicit cancellation handle threaded through the run. #[must_use] - pub fn cancel(mut self, handle: CancelHandle) -> RunConfig { + pub fn cancel(mut self, handle: CancelHandle) -> RunContext { self.cancel = Some(handle); self } /// Sets the resource limits honored across the run. #[must_use] - pub fn limits(mut self, limits: RunLimits) -> RunConfig { + pub fn limits(mut self, limits: RunLimits) -> RunContext { self.limits = limits; self } @@ -260,7 +295,7 @@ impl RunConfig { /// [`INPUT_UNAVAILABLE_FALLBACK`](crate::input::INPUT_UNAVAILABLE_FALLBACK) /// with `available` false. #[must_use] - pub fn input_broker(mut self, broker: Arc) -> RunConfig { + pub fn input_broker(mut self, broker: Arc) -> RunContext { self.input = Some(broker); self } @@ -273,7 +308,7 @@ impl RunConfig { /// without declaring its model. The default (`None`) installs no `ui` /// global and keeps strict declared-alias resolution. #[must_use] - pub fn ui(mut self, provider: Arc serde_json::Value + Send + Sync>) -> RunConfig { + pub fn ui(mut self, provider: Arc serde_json::Value + Send + Sync>) -> RunContext { self.ui = Some(provider); self } @@ -282,7 +317,7 @@ impl RunConfig { /// forward their chunks to. The default (`None`) drops deltas at the /// leaf. #[must_use] - pub fn on_delta(mut self, hook: Arc) -> RunConfig { + pub fn on_delta(mut self, hook: Arc) -> RunContext { self.on_delta = Some(hook); self } @@ -293,22 +328,38 @@ impl RunConfig { /// 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 { + pub fn vfs(mut self, vfs: VfsRef) -> RunContext { self.vfs = vfs; self } - /// Returns the execution identifier shared by every report. + /// Returns the run identity shared by every report. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Returns when the context was created. + #[must_use] + pub fn start_time(&self) -> SystemTime { + self.start_time + } + + /// Returns the model-orchestrated prompt-tool nesting depth (always 0 + /// for a root run; the sub-run adapter that increments it lands with + /// the deferred prompt-pack). #[must_use] - pub fn execution(&self) -> &str { - &self.execution + pub fn depth(&self) -> u32 { + self.depth } } -impl fmt::Debug for RunConfig { +impl fmt::Debug for RunContext { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("RunConfig") - .field("execution", &self.execution) + f.debug_struct("RunContext") + .field("name", &self.name) + .field("start_time", &self.start_time) + .field("depth", &self.depth) .field("observer", &"") .field("client", &self.client) .field("debug", &self.debug.as_ref().map(|_| "")) @@ -318,6 +369,7 @@ impl fmt::Debug for RunConfig { .field("ui", &self.ui.is_some()) .field("on_delta", &self.on_delta.is_some()) .field("vfs", &self.vfs) + .field("resolution", &self.resolution.is_some()) .finish() } } diff --git a/crates/promptforge-api/src/execute/context.rs b/crates/promptforge-api/src/execute/context.rs index 6dc16927..2fb8140f 100644 --- a/crates/promptforge-api/src/execute/context.rs +++ b/crates/promptforge-api/src/execute/context.rs @@ -1,7 +1,7 @@ //! The execute subtree's ambient run state. //! -//! [`RunContext`] is built once in [`run`](super::run) and travels through -//! the execute subtree as parameter one (`ctx: &RunContext`). The +//! [`RunState`] is built once in [`run`](super::run) and travels through +//! the execute subtree as parameter one (`ctx: &RunState`). The //! invariant: a new run-scoped concern becomes a field here, never a new //! parameter. Per-call data (a section, a `var` snapshot) stays //! in parameters or on the per-section frame. @@ -20,7 +20,7 @@ use crate::parser::Prompt; use crate::store::{Access, VfsRef}; use crate::untrusted::GuardNonce; -use super::config::{RunConfig, RunLimits}; +use super::config::{RunContext, RunLimits}; use super::section_vm::{SectionVmSetup, VmSeed}; use super::support::{now_rfc3339_checked, sys_json}; @@ -34,7 +34,7 @@ use super::support::{now_rfc3339_checked, sys_json}; /// proxy reporting handles, and [`with_args`](Self::with_args) carrying a /// `call` call's args override into its contained chain. #[derive(Clone)] -pub(crate) struct RunContext { +pub(crate) struct RunState { /// The prompt this run executes. prompt: Arc, /// The untrusted-envelope nonce, minted once here so every wrap in the @@ -97,7 +97,7 @@ pub(crate) struct RunContext { on_delta: Option>, } -impl RunContext { +impl RunState { /// Builds the context for one run of `prompt`. The turn and id counters /// are minted here (both start at zero), as are the empty tool and model /// sets the live H1 pass fills through the concrete handles; `when` @@ -108,7 +108,7 @@ impl RunContext { args: &str, vfs: &VfsRef, shared: LuaProgram, - config: &RunConfig, + ctx: &RunContext, ) -> Self { let tool_set = Arc::new(Mutex::new(ToolSet::default())); let model_set = Arc::new(Mutex::new(ModelSet::default())); @@ -116,11 +116,11 @@ impl RunContext { prompt: Arc::new(prompt.clone()), nonce: GuardNonce::fresh(), vfs: vfs.clone(), - execution: Arc::from(config.execution.as_str()), + execution: Arc::from(ctx.name.as_str()), args: Arc::from(args), - limits: config.limits, - observer: Arc::clone(&config.observer), - debug: config.debug.clone(), + limits: ctx.limits, + observer: Arc::clone(&ctx.observer), + debug: ctx.debug.clone(), turns: Arc::new(AtomicU32::new(0)), ids: Arc::new(AtomicU64::new(0)), shared: Arc::new(shared), @@ -129,9 +129,9 @@ impl RunContext { models: model_set.clone(), model_set, when: Arc::from(""), - input: config.input.clone(), - ui: config.ui.clone(), - on_delta: config.on_delta.clone(), + input: ctx.input.clone(), + ui: ctx.ui.clone(), + on_delta: ctx.on_delta.clone(), } } @@ -353,9 +353,9 @@ impl RunContext { } } -impl fmt::Debug for RunContext { +impl fmt::Debug for RunState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("RunContext") + f.debug_struct("RunState") .field("prompt", &self.prompt) .field("nonce", &self.nonce) .field("vfs", &"") @@ -393,13 +393,13 @@ mod tests { .expect("the test prompt parses") } - fn test_context(prompt: &Prompt) -> RunContext { - RunContext::new( + fn test_context(prompt: &Prompt) -> RunState { + RunState::new( prompt, "", &promptforge_vfs::empty(), LuaProgram::empty().expect("the empty chunk compiles"), - &RunConfig::new("run-context-test"), + &RunContext::new("run-context-test"), ) } diff --git a/crates/promptforge-api/src/execute/environment.rs b/crates/promptforge-api/src/execute/environment.rs new file mode 100644 index 00000000..bb96a0a8 --- /dev/null +++ b/crates/promptforge-api/src/execute/environment.rs @@ -0,0 +1,147 @@ +//! The deployment environment: [`Environment`]. + +use std::fmt; +use std::sync::Arc; + +use promptforge_tool_picker::ToolPicker; + +use crate::client::GatewayClient; +use crate::model::ModelCatalog; +use crate::parser::Prompt; +use crate::store::VfsRef; +use crate::tools::ToolCatalog; + +use super::RunResult; +use super::config::{RunContext, RunResolution}; + +/// What exists in this deployment and its standing policy. +/// +/// Safe to share across concurrent [`run`](Environment::run) calls +/// (`Sync`); built once per host and never rebuilt: everything that can +/// change per run rides the [`RunContext`]. Model-free: the gateway's +/// model list is a host-UI concern and never crosses this interface. +/// +/// Interim state (the interface consolidation step): the environment +/// absorbs the retired resolution context's contents - the picker, the +/// model catalog, and the tool catalog - as internal fields, and prose +/// binding still works. The registry slot, the per-run router built from +/// `base_vfs`, and the `max_depth` guard all land with the prepare pass +/// in the capability-binding work; until then `base_vfs` and `max_depth` +/// are carried, not consulted. +#[non_exhaustive] +pub struct Environment { + /// Semantic picker behind executed H1 binds (interim home, absorbed + /// from the retired resolution context). + picker: Option>, + /// Live model catalog behind executed H1 model calls (interim home). + models: ModelCatalog, + /// Tool catalog behind executed H1 `tools.bind` calls (interim home). + tools: ToolCatalog, + /// The deployment's gateway client; a run's own client overrides it. + client: Option, + /// Host roots the per-run router mounts; never carries the store + /// mount. Inert until the prepare pass lands. + base_vfs: VfsRef, + /// Maximum model-orchestrated prompt-tool nesting, copied into every + /// run. Inert until the sub-run adapter lands with the prompt-pack. + max_depth: u32, +} + +impl Environment { + /// Builds the default environment: no picker, empty model and tool + /// catalogs, no client, no host roots, and a nesting cap of 3. + #[must_use] + pub fn new() -> Environment { + Environment { + picker: None, + models: ModelCatalog::default(), + tools: ToolCatalog::default(), + client: None, + base_vfs: VfsRef::builder().build(), + max_depth: 3, + } + } + + /// Sets the semantic picker executed H1 binds resolve through. + #[must_use] + pub fn picker(mut self, picker: ToolPicker) -> Environment { + self.picker = Some(Arc::new(picker)); + self + } + + /// Sets the live model catalog executed H1 model calls resolve against. + #[must_use] + pub fn models(mut self, models: ModelCatalog) -> Environment { + self.models = models; + self + } + + /// Sets the tool catalog executed H1 `tools.bind` calls resolve against. + #[must_use] + pub fn tools(mut self, tools: ToolCatalog) -> Environment { + self.tools = tools; + self + } + + /// Sets the deployment's gateway client; a run's own client overrides + /// it, and with neither, one is built from the process environment on + /// first use. + #[must_use] + pub fn client(mut self, client: GatewayClient) -> Environment { + self.client = Some(client); + self + } + + /// Sets the host roots the per-run router mounts. Consulted by the + /// prepare pass when it lands; carried inert until then. + #[must_use] + pub fn base_vfs(mut self, vfs: VfsRef) -> Environment { + self.base_vfs = vfs; + self + } + + /// Sets the maximum model-orchestrated prompt-tool nesting depth. + /// Consulted by the sub-run adapter when it lands with the + /// prompt-pack; carried inert until then. + #[must_use] + pub fn max_depth(mut self, max_depth: u32) -> Environment { + self.max_depth = max_depth; + self + } + + /// The zero-burden path: installs the environment's live resolution + /// inputs and client default on the context - the interim stand-in for + /// the prepare pass, which will also fail unsatisfiable requirements + /// here - and runs the prompt. + pub async fn run(&self, prompt: &Prompt, args: &str, ctx: RunContext) -> RunResult { + let mut ctx = ctx; + ctx.resolution = Some(RunResolution { + picker: self.picker.clone(), + models: self.models.clone(), + tools: self.tools.clone(), + }); + if ctx.client.is_none() { + ctx.client = self.client.clone(); + } + super::run(prompt, args, ctx).await + } +} + +impl Default for Environment { + fn default() -> Environment { + Environment::new() + } +} + +impl fmt::Debug for Environment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Environment") + .field("picker", &self.picker.is_some()) + .field("models", &self.models) + .field("tools", &"") + .field("client", &self.client) + .field("base_vfs", &self.base_vfs) + .field("max_depth", &self.max_depth) + .finish() + } +} diff --git a/crates/promptforge-api/src/execute/error.rs b/crates/promptforge-api/src/execute/error.rs index 0b63dfff..a12a7ba3 100644 --- a/crates/promptforge-api/src/execute/error.rs +++ b/crates/promptforge-api/src/execute/error.rs @@ -42,6 +42,8 @@ pub enum RunErrorKind { Cancelled, /// An unexpected internal invariant failure. Internal, + /// An H1 assertion or model requirement the environment cannot satisfy. + RequirementsUnmet, } /// The error returned by [`run`](super::run), the orchestration boundary of a diff --git a/crates/promptforge-api/src/execute/gateway.rs b/crates/promptforge-api/src/execute/gateway.rs index e13b26ca..5ba532d9 100644 --- a/crates/promptforge-api/src/execute/gateway.rs +++ b/crates/promptforge-api/src/execute/gateway.rs @@ -12,9 +12,13 @@ use crate::{Error, Result}; use super::config::RunLimits; /// Live capability inputs for the parse-to-run execution path. +/// +/// Crate-internal: the public interface carries these on the +/// [`Environment`](super::Environment), which installs them on the +/// [`RunContext`](super::RunContext) before the free [`run`](super::run) +/// borrows them back into this borrowed shape for the live H1 pass. #[derive(Clone, Copy)] -#[non_exhaustive] -pub struct ResolutionContext<'a> { +pub(crate) struct ResolutionContext<'a> { /// 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. @@ -28,8 +32,7 @@ pub struct ResolutionContext<'a> { impl<'a> ResolutionContext<'a> { /// Builds a resolution context from an optional live picker, a model /// catalog, and a tool catalog. - #[must_use] - pub fn new( + pub(crate) fn new( picker: Option<&'a ToolPicker>, models: &'a ModelCatalog, tools: &'a ToolCatalog, diff --git a/crates/promptforge-api/src/execute/scheduler.rs b/crates/promptforge-api/src/execute/scheduler.rs index 4761d916..e7e791cb 100644 --- a/crates/promptforge-api/src/execute/scheduler.rs +++ b/crates/promptforge-api/src/execute/scheduler.rs @@ -19,9 +19,9 @@ //! ready the driver awaits the answer channel or cancellation, whichever //! comes first. //! -//! [`RunContext`] stays the ambient shared read-mostly context, borrowed by +//! [`RunState`] stays the ambient shared read-mostly context, borrowed by //! chain steps; the scheduler is the exclusively owned mutable counterpart. -//! The two are deliberately not merged: `RunContext` is cloned into +//! The two are deliberately not merged: `RunState` is cloned into //! callbacks, while the scheduler must stay unreachable from the callback //! layer. //! @@ -82,7 +82,7 @@ use crate::store::{Access, Store, StoreError}; use crate::tools::ToolId; use crate::{Error, Result, cancel, subst}; -use super::context::RunContext; +use super::context::RunState; use super::engine::{ JumpTarget, home_without, resolve_jump_target, section_position, visible_sections, }; @@ -178,7 +178,7 @@ struct ArmTemplate<'a> { /// (the legacy proxies exist to cross the spawned-task boundary, which /// a chain never crosses) with a fresh turn counter, so arm turns count /// against the fanout's own cap. - ctx: RunContext, + ctx: RunState, /// The fanout caller's access capability: each arm spawns its own /// capability from it at dispatch, so the spawn retires the caller's /// claims (the happens-before edge) and two live arms touching one @@ -314,7 +314,7 @@ fn resolve_arm_target<'a>( struct Chain<'a> { /// The chain's fork of the run context: the run's own for the root /// chain, `with_args` for a call chain's input override. - ctx: RunContext, + ctx: RunState, /// The chain's VFS access capability, installed into each section VM /// the chain enters: the walk and the live H1 pass acquire their own, /// a call chain borrows its parent's (a blocking child is the same @@ -462,7 +462,7 @@ fn classify_store_failure(error: &StoreError) -> Error { pub(crate) struct Scheduler<'a> { /// The ambient run context, borrowed by chain steps and forked by /// call chains. - ctx: &'a RunContext, + ctx: &'a RunState, /// The chain arena: append-only, indexed by [`ChainId`]. chains: Vec>, /// The call-nesting chain stack (LIFO): a call dispatch pushes @@ -538,7 +538,7 @@ 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 /// chain builds one from the environment on first inference. - pub(crate) fn new(ctx: &'a RunContext, client: Option) -> Self { + pub(crate) fn new(ctx: &'a RunState, client: Option) -> Self { let (answer_tx, answers) = mpsc::unbounded_channel(); Self { ctx, @@ -745,7 +745,7 @@ impl<'a> Scheduler<'a> { )] fn start_chain( &mut self, - ctx: RunContext, + ctx: RunState, slice: &'a [Section], index: usize, parent: Option, diff --git a/crates/promptforge-api/src/execute/section_context.rs b/crates/promptforge-api/src/execute/section_context.rs index b931eb82..747a4dd8 100644 --- a/crates/promptforge-api/src/execute/section_context.rs +++ b/crates/promptforge-api/src/execute/section_context.rs @@ -17,7 +17,7 @@ //! //! The run-scoped inputs //! (bindings, models, limits, the shared tools) arrive through the -//! [`RunContext`]. +//! [`RunState`]. use std::sync::Arc; use std::sync::atomic::AtomicU32; @@ -32,7 +32,7 @@ use crate::parser::Section; use crate::store::Access; use crate::{Error, Result, subst}; -use super::context::RunContext; +use super::context::RunState; use super::engine::{list_items_from_visible, visible_sections}; use super::section_vm::{VmSeed, setup_section_vm}; use super::support::{next_id, now_rfc3339_checked, sys_json}; @@ -115,7 +115,7 @@ impl SectionContext { /// observation exists; a setup failure tears the fresh VM down first, so /// the teardown boundary still fires exactly once on that path. pub(crate) fn new( - ctx: &RunContext, + ctx: &RunState, access: &Arc, section: &Section, siblings: &[Section], @@ -196,7 +196,7 @@ impl SectionContext { /// construction or limits failure propagates bare, before any teardown /// observation exists; a setup failure tears the fresh VM down first, so /// the teardown boundary still fires exactly once on that path. - pub(crate) fn new_live_h1(ctx: &RunContext, access: &Arc) -> Result { + pub(crate) fn new_live_h1(ctx: &RunState, access: &Arc) -> Result { let title = ctx.prompt().title(); let now = now_rfc3339_checked()?; let sys = sys_json( @@ -262,7 +262,7 @@ impl SectionContext { /// construction phase keeps its own and every path tears down exactly /// once. pub(crate) fn new_fanout_arm( - ctx: &RunContext, + ctx: &RunState, access: &Arc, worker: &Section, home: &[Section], @@ -390,7 +390,7 @@ impl SectionContext { /// # Errors /// Returns [`Error::Lua`] if the guard cannot be installed, or /// [`Error::Internal`] if the VM is gone. - pub(crate) fn install_lazy_prose(&self, ctx: &RunContext, template: &str) -> Result<()> { + pub(crate) fn install_lazy_prose(&self, ctx: &RunState, template: &str) -> Result<()> { let template = template.to_owned(); let args = ctx.args().to_owned(); let item = self.item.clone(); @@ -434,7 +434,7 @@ impl SectionContext { /// alias seeding. pub(crate) fn script_call_counts( &mut self, - ctx: &RunContext, + ctx: &RunState, effective: &[ToolBinding], ) -> Result { let Self { @@ -468,7 +468,7 @@ impl SectionContext { /// the `sys` re-seal. fn install_section_scope( vm: &SectionVm, - ctx: &RunContext, + ctx: &RunState, sys: &mut serde_json::Value, counts: &mut Option, effective_bindings: &[ToolBinding], @@ -495,7 +495,7 @@ fn install_section_scope( /// Returns the [`Error`](crate::Error) of whichever step failed. fn setup_live_h1( vm: &mut SectionVm, - ctx: &RunContext, + ctx: &RunState, access: &Arc, sys: &serde_json::Value, title: &str, diff --git a/crates/promptforge-api/src/execute/tests/exec_flow.rs b/crates/promptforge-api/src/execute/tests/exec_flow.rs index b7a762e2..e4e73877 100644 --- a/crates/promptforge-api/src/execute/tests/exec_flow.rs +++ b/crates/promptforge-api/src/execute/tests/exec_flow.rs @@ -2300,15 +2300,19 @@ async fn a_mount_less_handle_runs_on_the_defensive_store_overlay() { ); let test = fixture(md); let vfs = VfsRef::new(shared_vfs::MemoryBackend::new()); - let picker = empty_test_picker(); - let out = crate::execute::run( - &test.prompt, - "", - 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"); + let env = Environment::new() + .picker(empty_test_picker()) + .models(test.models.clone()); + let RunResult::Ok(out) = env + .run( + &test.prompt, + "", + RunContext::new(EXECUTION).vfs(vfs.clone()), + ) + .await + else { + panic!("a mount-less handle gets the defensive memory-store overlay"); + }; assert_eq!( out, "overlaid", "the first section's write must be readable from the overlaid store" @@ -2329,26 +2333,22 @@ async fn a_mount_less_handle_runs_on_the_defensive_store_overlay() { #[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, ...)`. + // a picker-less `Environment`. 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"); + let env = Environment::new().models(test.models.clone()); + let RunResult::Ok(out) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await else { + panic!("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()` +async fn default_run_context_store_handle_carries_the_stock_mount() { + // `RunContext` 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\ @@ -2356,14 +2356,10 @@ async fn default_run_config_store_handle_carries_the_stock_mount() { ## 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"); + let env = Environment::new().models(test.models.clone()); + let RunResult::Ok(out) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await else { + panic!("the default store handle carries the stock mount"); + }; assert_eq!(out, "stock"); } @@ -2377,14 +2373,11 @@ async fn picker_less_context_fails_a_tool_bind_as_a_binding_error() { ## 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"); + let env = Environment::new().models(test.models.clone()); + let RunResult::Failure(error) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await + else { + panic!("a tools.bind without a picker must fail"); + }; assert_eq!( error.kind(), RunErrorKind::Binding, @@ -2407,14 +2400,11 @@ async fn picker_less_context_fails_a_model_bind_as_a_binding_error() { ); 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"); + let env = Environment::new().models(test.models.clone()); + let RunResult::Failure(error) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await + else { + panic!("a models.bind without a picker must fail"); + }; assert_eq!( error.kind(), RunErrorKind::Binding, diff --git a/crates/promptforge-api/src/execute/tests/input.rs b/crates/promptforge-api/src/execute/tests/input.rs index e620ab94..a4eb9260 100644 --- a/crates/promptforge-api/src/execute/tests/input.rs +++ b/crates/promptforge-api/src/execute/tests/input.rs @@ -32,9 +32,9 @@ fn input_models() -> ModelSet { /// Builds the run context for an input test: the parsed prompt, an empty /// shared library, and the shared model and tool sets pre-filled (the /// scheduler tests bypass the live H1 pass that would fill them). The -/// broker arrives through the [`RunConfig`]. -fn input_context(prompt: &Prompt, tools: ToolSet, config: &RunConfig) -> RunContext { - let ctx = RunContext::new( +/// broker arrives through the [`RunContext`]. +fn input_context(prompt: &Prompt, tools: ToolSet, config: &RunContext) -> RunState { + let ctx = RunState::new( prompt, "", &TestStore::new(), @@ -162,7 +162,7 @@ async fn user_input_returns_the_operator_text_with_available_true() { ); let prompt = parse(&md); let recorder = Arc::new(InputRecorder::default()); - let config = RunConfig::new(EXECUTION) + let config = RunContext::new(EXECUTION) .observer(Arc::clone(&recorder) as Arc) .input_broker(Arc::new(TextBroker("hello operator"))); let ctx = input_context(&prompt, ToolSet::default(), &config); @@ -197,7 +197,7 @@ async fn identical_human_text_cannot_spoof_the_unavailable_fallback() { // The operator types exactly the fallback sentence: the availability // flag still distinguishes it from the unavailable policy's answer. let config = - RunConfig::new(EXECUTION).input_broker(Arc::new(TextBroker(INPUT_UNAVAILABLE_FALLBACK))); + RunContext::new(EXECUTION).input_broker(Arc::new(TextBroker(INPUT_UNAVAILABLE_FALLBACK))); let ctx = input_context(&prompt, ToolSet::default(), &config); let out = Scheduler::new(&ctx, None) .drive() @@ -218,7 +218,7 @@ async fn a_run_without_a_broker_gets_the_unavailable_fallback() { ); let prompt = parse(&md); let recorder = Arc::new(InputRecorder::default()); - let config = RunConfig::new(EXECUTION).observer(Arc::clone(&recorder) as Arc); + let config = RunContext::new(EXECUTION).observer(Arc::clone(&recorder) as Arc); let ctx = input_context(&prompt, ToolSet::default(), &config); let out = Scheduler::new(&ctx, None) .drive() @@ -249,7 +249,7 @@ async fn an_unavailable_broker_answer_is_the_fallback() { ); let prompt = parse(&md); let recorder = Arc::new(InputRecorder::default()); - let config = RunConfig::new(EXECUTION) + let config = RunContext::new(EXECUTION) .observer(Arc::clone(&recorder) as Arc) .input_broker(Arc::new(UnavailableBroker)); let ctx = input_context(&prompt, ToolSet::default(), &config); @@ -272,7 +272,7 @@ async fn a_broker_failure_raises_at_the_call_site() { return err", ); let prompt = parse(&md); - let config = RunConfig::new(EXECUTION).input_broker(Arc::new(FailingBroker)); + let config = RunContext::new(EXECUTION).input_broker(Arc::new(FailingBroker)); let ctx = input_context(&prompt, ToolSet::default(), &config); let out = Scheduler::new(&ctx, None) .drive() @@ -288,7 +288,7 @@ async fn a_broker_failure_raises_at_the_call_site() { async fn an_uncaught_broker_failure_fails_the_run_typed() { let md = input_prompt("user_input()\nreturn 'unreachable'"); let prompt = parse(&md); - let config = RunConfig::new(EXECUTION).input_broker(Arc::new(FailingBroker)); + let config = RunContext::new(EXECUTION).input_broker(Arc::new(FailingBroker)); let ctx = input_context(&prompt, ToolSet::default(), &config); let error = Scheduler::new(&ctx, None) .drive() @@ -308,7 +308,7 @@ async fn cancellation_interrupts_a_pending_input_wait() { let md = input_prompt("user_input()\nreturn 'unreachable'"); let prompt = parse(&md); - let config = RunConfig::new(EXECUTION).input_broker(Arc::new(PendingBroker)); + let config = RunContext::new(EXECUTION).input_broker(Arc::new(PendingBroker)); let ctx = input_context(&prompt, ToolSet::default(), &config); let handle = CancelHandle::new(); let canceller = handle.clone(); @@ -340,7 +340,7 @@ async fn a_brokered_loop_with_no_prompt_tools_advertises_no_tools_to_the_model() return msgs[#msgs].content", ); let prompt = parse(&md); - let config = RunConfig::new(EXECUTION).input_broker(Arc::new(TextBroker("never asked"))); + let config = RunContext::new(EXECUTION).input_broker(Arc::new(TextBroker("never asked"))); let ctx = input_context(&prompt, ToolSet::default(), &config); let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) .drive() diff --git a/crates/promptforge-api/src/execute/tests/live_infer.rs b/crates/promptforge-api/src/execute/tests/live_infer.rs index 1fa929bc..1d89856f 100644 --- a/crates/promptforge-api/src/execute/tests/live_infer.rs +++ b/crates/promptforge-api/src/execute/tests/live_infer.rs @@ -17,19 +17,54 @@ async fn live_h1_infer_runs_once() { let prompt = parse(source); let picker = empty_test_picker(); let models = test_model_catalog(); - let out = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(gatewayed(addr)), - ) - .await - .expect("live H1 path must run"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let RunResult::Ok(out) = env.run(&prompt, "", to_context(gatewayed(addr))).await else { + panic!("live H1 path must run"); + }; assert_eq!(out, "h1 answer"); assert_eq!(gateway.call_count(), 1); } +#[tokio::test(flavor = "multi_thread")] +async fn the_environment_client_serves_a_run_when_the_context_carries_none() { + // `Environment::run` defaults a client-less context to the environment's + // client: the run's own client overrides it, and with none on the + // context the environment's client must serve the run's completions. + let gateway = ScriptedGateway::start(vec![resp_text("env answer")]).await; + let addr = gateway.addr(); + + let source = "---\nname: env-client\ndescription: d\npromptforge: 0\n---\n\n\ + # Env Client\n\n\ + ```lua\n\ + local writer = models.default('writer', 'A general model for tests')\n\ + var.answer = models.infer(writer, 'answer once')\n\ + ```\n\n\ + ## Result\n\n\ + ```lua\nreturn var.answer\n```\n"; + let prompt = parse(source); + let env = Environment::new() + .picker(empty_test_picker()) + .models(test_model_catalog()) + .tools(ToolCatalog::default()) + .client(gateway_client(addr)); + // The context deliberately carries no client: the defaulting in + // `Environment::run` is the only path to the gateway. + let RunResult::Ok(out) = env.run(&prompt, "", to_context(silent())).await else { + panic!("the environment's client must serve a client-less context"); + }; + + assert_eq!(out, "env answer"); + assert_eq!( + gateway.call_count(), + 1, + "the completion must have gone to the environment's client" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { // H1 prose no longer drives inference: an unread buffer - even one @@ -97,14 +132,16 @@ async fn shared_function_resolves_host_globals_when_called() { let prompt = parse(source); let picker = empty_test_picker(); let models = test_model_catalog(); - let out = super::super::run( - &prompt, - "later host value", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(silent()), - ) - .await - .expect("shared function must resolve host globals when called"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let RunResult::Ok(out) = env + .run(&prompt, "later host value", to_context(silent())) + .await + else { + panic!("shared function must resolve host globals when called"); + }; assert_eq!(out, "later host value"); } @@ -126,14 +163,20 @@ async fn shared_library_calls_host_apis_at_load_time() { ## Result\n\n\ ```lua\nreturn store.read('loaded.txt')\n```\n"; let prompt = parse(source); - let out = super::super::run( - &prompt, - "load-time args", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(silent()).vfs(store.vfs().clone()), - ) - .await - .expect("top-level shared host calls must succeed"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let RunResult::Ok(out) = env + .run( + &prompt, + "load-time args", + to_context(silent()).vfs(store.vfs().clone()), + ) + .await + else { + panic!("top-level shared host calls must succeed"); + }; assert_eq!(out, "load-time args"); assert_eq!( @@ -184,14 +227,13 @@ async fn captured_bindings_reach_section_call_and_fanout_vms() { let models = test_model_catalog(); let tools: [Arc; 1] = [echo]; let catalog = ToolCatalog::new(&tools).expect("the fixture tool is unique"); - let out = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &catalog), - to_config(silent()), - ) - .await - .expect("captured bindings must be installed in every section VM"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(catalog); + let RunResult::Ok(out) = env.run(&prompt, "", to_context(silent())).await else { + panic!("captured bindings must be installed in every section VM"); + }; assert_eq!( out, @@ -217,14 +259,16 @@ async fn live_h1_models_infer_resolves_the_default_model_without_touching_sys() let prompt = parse(source); let picker = empty_test_picker(); let models = test_model_catalog(); - let out = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(gatewayed(gateway.addr())), - ) - .await - .expect("live H1 models.infer must run"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let RunResult::Ok(out) = env + .run(&prompt, "", to_context(gatewayed(gateway.addr()))) + .await + else { + panic!("live H1 models.infer must run"); + }; assert_eq!(out, "h1 answer:true"); assert_eq!(gateway.call_count(), 1); @@ -265,20 +309,26 @@ async fn nested_lua_infer_emits_a_model_turn_observation() { let picker = empty_test_picker(); let models = test_model_catalog(); let recorder = Arc::new(Recorder::default()); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); - let out = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(RunOptions { - execution: EXECUTION, - observer: Arc::clone(&recorder) as Arc, - client: Some(gateway_client(addr)), - debug: None, - }), - ) - .await - .expect("nested infer must run"); + let RunResult::Ok(out) = env + .run( + &prompt, + "", + to_context(RunOptions { + execution: EXECUTION, + observer: Arc::clone(&recorder) as Arc, + client: Some(gateway_client(addr)), + debug: None, + }), + ) + .await + else { + panic!("nested infer must run"); + }; assert_eq!(out, "pong"); let details: Vec = recorder @@ -329,20 +379,23 @@ async fn cancelled_nested_infer_does_not_report_model_turn_failed() { .await; canceller.cancel(); }); - let error = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - RunConfig::new(EXECUTION) - .observer(Arc::clone(&recorder) as Arc) - .client(gateway_client(gateway.addr())) - .cancel(cancel), - ) - .await - .expect_err("cancelling an in-flight infer must interrupt the run"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let result = env + .run( + &prompt, + "", + RunContext::new(EXECUTION) + .observer(Arc::clone(&recorder) as Arc) + .client(gateway_client(gateway.addr())) + .cancel(cancel), + ) + .await; assert!( - error.to_string().contains("interrupted"), - "expected interruption, got {error}" + matches!(result, RunResult::Cancelled), + "cancelling an in-flight infer must interrupt the run: {result:?}" ); assert_eq!( gateway.call_count(), @@ -412,14 +465,13 @@ async fn live_h1_prose_infers_explicitly_and_var_accumulates_into_the_walk() { let prompt = parse(source); let picker = empty_test_picker(); let models = test_model_catalog(); - let out = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(gatewayed(addr)), - ) - .await - .expect("live H1 prose infers explicitly"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let RunResult::Ok(out) = env.run(&prompt, "", to_context(gatewayed(addr))).await else { + panic!("live H1 prose infers explicitly"); + }; assert_eq!(out, "final answer:2"); assert_eq!(gateway.call_count(), 1); @@ -447,14 +499,16 @@ async fn h1_and_h2_prose_each_infer_explicitly_in_source_order() { let prompt = parse(source); let picker = empty_test_picker(); let models = test_model_catalog(); - let out = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(gatewayed(gateway.addr())), - ) - .await - .expect("H1 prose and H2 prose each infer explicitly"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let RunResult::Ok(out) = env + .run(&prompt, "", to_context(gatewayed(gateway.addr()))) + .await + else { + panic!("H1 prose and H2 prose each infer explicitly"); + }; assert_eq!(out, "h2 reply"); assert_eq!( @@ -496,14 +550,13 @@ async fn live_h1_chunk_keeps_sys_id_zero_and_the_first_walked_section_takes_one( let prompt = parse(source); let picker = empty_test_picker(); let models = test_model_catalog(); - let out = super::super::run( - &prompt, - "", - ResolutionContext::new(Some(&picker), &models, &ToolCatalog::default()), - to_config(silent()), - ) - .await - .expect("the H1 chunk keeps id 0 and the first walked section takes id 1"); + let env = Environment::new() + .picker(picker) + .models(models) + .tools(ToolCatalog::default()); + let RunResult::Ok(out) = env.run(&prompt, "", to_context(silent())).await else { + panic!("the H1 chunk keeps id 0 and the first walked section takes id 1"); + }; assert_eq!(out, "ok"); } diff --git a/crates/promptforge-api/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs index d9f8e2c9..900e7dd4 100644 --- a/crates/promptforge-api/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -47,18 +47,21 @@ const EXECUTION: &str = "execute-test"; /// F10: compile-time proof that the public execution types are thread-safe. /// -/// `RunConfig` carries `Arc` / `Arc` (shared +/// `RunContext` carries `Arc` / `Arc` (shared /// trait objects) and must be `Send + Sync + 'static` to cross the run's task -/// boundaries; the typed error/limit/resolution surfaces must be too. +/// boundaries; the typed error/limit/result surfaces and the environment must +/// be too. const fn _public_execution_types_are_send_sync_static() { const fn assert_send_sync_static() {} const fn assert_send_sync() {} - assert_send_sync_static::(); + assert_send_sync_static::(); + assert_send_sync_static::(); assert_send_sync_static::(); + assert_send_sync_static::(); assert_send_sync_static::(); assert_send_sync_static::(); - // Borrowing resolution context: a fixed concrete lifetime still proves the - // auto traits hold for its owned shape. + // Borrowing resolution inputs: a fixed concrete lifetime still proves the + // auto traits hold for their owned shape. assert_send_sync::>(); } @@ -194,9 +197,9 @@ fn bound_with_tools( } } -/// Owned run inputs a test supplies: the execution id, the progress observer, +/// Owned run inputs a test supplies: the run name, the progress observer, /// and optional client/capture sinks. Mirrors the old borrowed `RunOptions` -/// with owned `Arc` instrumentation so it can build a [`RunConfig`]. +/// with owned `Arc` instrumentation so it can build a [`RunContext`]. struct RunOptions { execution: &'static str, observer: Arc, @@ -253,17 +256,18 @@ impl TestStore { } } -/// Builds a [`RunConfig`] from the test-local [`RunOptions`], for the tests that -/// call [`super::run`] directly with a custom picker and model catalog. -fn to_config(opts: RunOptions) -> RunConfig { - let mut config = RunConfig::new(opts.execution).observer(opts.observer); +/// Builds a [`RunContext`] from the test-local [`RunOptions`], for the tests +/// that call [`Environment::run`] directly with a custom picker and model +/// catalog. +fn to_context(opts: RunOptions) -> RunContext { + let mut ctx = RunContext::new(opts.execution).observer(opts.observer); if let Some(client) = opts.client { - config = config.client(client); + ctx = ctx.client(client); } if let Some(debug) = opts.debug { - config = config.debug(debug); + ctx = ctx.debug(debug); } - config + ctx } /// Options that report nowhere and build no client - what a Lua-only, @@ -342,21 +346,24 @@ async fn run( .expect("test thresholds are in the supported domain"); let picker = build_test_picker(catalog, config); let tool_catalog = ToolCatalog::new(tools).expect("fixture tools are unique"); - let mut run_config = RunConfig::new(opts.execution).observer(opts.observer); + let env = Environment::new() + .picker(picker) + .models(test.models.clone()) + .tools(tool_catalog); + let mut ctx = RunContext::new(opts.execution) + .observer(opts.observer) + .vfs(store.vfs().clone()); if let Some(client) = opts.client { - run_config = run_config.client(client); + ctx = ctx.client(client); } if let Some(debug) = opts.debug { - run_config = run_config.debug(debug); + ctx = ctx.debug(debug); + } + match env.run(&test.prompt, args, ctx).await { + RunResult::Ok(output) => Ok(output), + RunResult::Cancelled => Err(Error::Interrupted), + RunResult::Failure(error) => Err(Error::from(error)), } - super::run( - &test.prompt, - args, - ResolutionContext::new(Some(&picker), &test.models, &tool_catalog), - run_config.vfs(store.vfs().clone()), - ) - .await - .map_err(Error::from) } pub(super) fn empty_test_picker() -> ToolPicker { @@ -389,21 +396,22 @@ pub(super) fn shared_test_model() -> &'static promptforge_tool_picker::Model { MODEL.get_or_init(|| promptforge_tool_picker::Model::load().expect("the test model loads")) } -/// Runs a fixture offline through the real [`run`](super::run) entry point -/// with a caller-customized [`RunConfig`], returning the typed [`RunError`] +/// Runs a fixture offline through the real [`Environment::run`] entry point +/// with a caller-customized [`RunContext`], returning the typed [`RunError`] /// so a test can assert on its kind (limits, cancellation). -async fn run_with_config( +async fn run_with_context( test: &TestPrompt, - configure: impl FnOnce(RunConfig) -> RunConfig, + configure: impl FnOnce(RunContext) -> RunContext, ) -> std::result::Result { - let picker = empty_test_picker(); - super::run( - &test.prompt, - "", - ResolutionContext::new(Some(&picker), &test.models, &ToolCatalog::default()), - configure(RunConfig::new(EXECUTION)).vfs(TestStore::new().vfs().clone()), - ) - .await + let env = Environment::new() + .picker(empty_test_picker()) + .models(test.models.clone()); + let ctx = configure(RunContext::new(EXECUTION)).vfs(TestStore::new().vfs().clone()); + match env.run(&test.prompt, "", ctx).await { + RunResult::Ok(output) => Ok(output), + RunResult::Cancelled => Err(RunError::from(Error::Interrupted)), + RunResult::Failure(error) => Err(error), + } } /// An [`Observer`] that keeps every observation it is handed, in order, so a test @@ -1404,14 +1412,14 @@ async fn run_with_a_pre_cancelled_handle_fails_as_cancelled() { use crate::cancel::CancelHandle; // The explicit-cancel wiring of the public entry point: a handle passed - // through `RunConfig::cancel` is installed around the whole run body, so + // through `RunContext::cancel` is installed around the whole run body, so // the section's Lua instruction hook observes it and the run maps the // interruption to `RunErrorKind::Cancelled`. let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ ## Loop\n\n```lua\nlocal n = 0\nwhile true do n = n + 1 end\n```\n"; let handle = CancelHandle::new(); handle.cancel(); - let error = run_with_config(&fixture(md), |config| config.cancel(handle)) + let error = run_with_context(&fixture(md), |ctx| ctx.cancel(handle)) .await .expect_err("a pre-cancelled handle must fail the run"); assert!( diff --git a/crates/promptforge-api/src/execute/tests/models_loop.rs b/crates/promptforge-api/src/execute/tests/models_loop.rs index 0090c737..d2d344e4 100644 --- a/crates/promptforge-api/src/execute/tests/models_loop.rs +++ b/crates/promptforge-api/src/execute/tests/models_loop.rs @@ -41,13 +41,13 @@ fn loop_models() -> ModelSet { /// Builds the run context for a loop test: the parsed prompt, an empty /// shared library, and the shared model and tool sets pre-filled (the /// scheduler tests bypass the live H1 pass that would fill them). -fn loop_context(prompt: &Prompt, tools: ToolSet) -> RunContext { - let ctx = RunContext::new( +fn loop_context(prompt: &Prompt, tools: ToolSet) -> RunState { + let ctx = RunState::new( prompt, "", &TestStore::new(), LuaProgram::empty().expect("the empty chunk compiles"), - &RunConfig::new(EXECUTION), + &RunContext::new(EXECUTION), ); *ctx.model_set() .lock() diff --git a/crates/promptforge-api/src/execute/tests/observations.rs b/crates/promptforge-api/src/execute/tests/observations.rs index 33f7ef4b..28db0b2d 100644 --- a/crates/promptforge-api/src/execute/tests/observations.rs +++ b/crates/promptforge-api/src/execute/tests/observations.rs @@ -249,9 +249,8 @@ async fn a_one_byte_limit_fails_host_injection_with_teardown_observations() { ## Only\n\n```lua\nreturn \"ran\"\n```\n"; let recorder = Arc::new(Recorder::default()); let sink = Arc::clone(&recorder) as Arc; - let result = run_with_config(&fixture(md), move |config| { - config - .observer(sink) + let result = run_with_context(&fixture(md), move |ctx| { + ctx.observer(sink) .limits(RunLimits::new().lua_memory_bytes(std::num::NonZeroUsize::MIN)) }) .await; diff --git a/crates/promptforge-api/src/execute/tests/scheduler.rs b/crates/promptforge-api/src/execute/tests/scheduler.rs index 2c0a78da..c6c9c9c7 100644 --- a/crates/promptforge-api/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api/src/execute/tests/scheduler.rs @@ -47,7 +47,7 @@ fn writer_models() -> ModelSet { /// Builds the run context for a scheduler test: the parsed prompt, an empty /// shared library, and the model set pre-filled. -fn scheduler_context(prompt: &Prompt) -> RunContext { +fn scheduler_context(prompt: &Prompt) -> RunState { scheduler_context_on(prompt, &TestStore::new(), Arc::new(NullObserver::default())) } @@ -57,13 +57,13 @@ fn scheduler_context_on( prompt: &Prompt, store: &TestStore, observer: Arc, -) -> RunContext { - let ctx = RunContext::new( +) -> RunState { + let ctx = RunState::new( prompt, "", store.vfs(), LuaProgram::empty().expect("the empty chunk compiles"), - &RunConfig::new(EXECUTION).observer(observer), + &RunContext::new(EXECUTION).observer(observer), ); *ctx.model_set() .lock() @@ -1246,20 +1246,20 @@ async fn a_failed_jump_resolution_still_finishes_the_jumper() { /// Builds the run context for a scheduler live-H1 test: the shared model /// set starts empty - the live H1 pass under test records its own /// bindings, exactly as the legacy run's H1 hand-off leaves them. -fn h1_context(prompt: &Prompt) -> RunContext { +fn h1_context(prompt: &Prompt) -> RunState { h1_context_on(prompt, &TestStore::new(), Arc::new(NullObserver::default())) } /// Builds the H1 run context on the given store and observer, so a pass /// test can inspect the store's contents and the observation stream /// afterward. -fn h1_context_on(prompt: &Prompt, store: &TestStore, observer: Arc) -> RunContext { - RunContext::new( +fn h1_context_on(prompt: &Prompt, store: &TestStore, observer: Arc) -> RunState { + RunState::new( prompt, "", store.vfs(), LuaProgram::empty().expect("the empty chunk compiles"), - &RunConfig::new(EXECUTION).observer(observer), + &RunContext::new(EXECUTION).observer(observer), ) } @@ -1860,13 +1860,13 @@ async fn the_live_h1_pass_fires_no_section_boundaries() { /// Builds the run context for a scheduler fanout test with the given /// limits, so a window test can narrow the concurrency. -fn scheduler_context_with_limits(prompt: &Prompt, limits: RunLimits) -> RunContext { - let ctx = RunContext::new( +fn scheduler_context_with_limits(prompt: &Prompt, limits: RunLimits) -> RunState { + let ctx = RunState::new( prompt, "", &TestStore::new(), LuaProgram::empty().expect("the empty chunk compiles"), - &RunConfig::new(EXECUTION).limits(limits), + &RunContext::new(EXECUTION).limits(limits), ); *ctx.model_set() .lock() @@ -2142,12 +2142,12 @@ async fn model_required_when_arm_infer_has_no_binding() { ```lua\nreturn models.infer(prose)\n```\n"; let prompt = parse(md); let shared = LuaProgram::empty().expect("the empty chunk compiles"); - let ctx = RunContext::new( + let ctx = RunState::new( &prompt, "", &TestStore::new(), shared, - &RunConfig::new(EXECUTION), + &RunContext::new(EXECUTION), ); let error = Scheduler::new(&ctx, None) .drive() @@ -2192,12 +2192,12 @@ async fn the_shared_replay_sees_the_arm_item() { .replay() .cloned() .expect("the prompt's shared chunk compiles at parse"); - let ctx = RunContext::new( + let ctx = RunState::new( &prompt, "", &TestStore::new(), shared, - &RunConfig::new(EXECUTION), + &RunContext::new(EXECUTION), ); let out = Scheduler::new(&ctx, None) .drive() @@ -2801,12 +2801,12 @@ async fn fatal_arm_aborts_queued_siblings() { return item\n\ ```\n"; let prompt = parse(md); - let ctx = RunContext::new( + let ctx = RunState::new( &prompt, "", &store, LuaProgram::empty().expect("the empty chunk compiles"), - &RunConfig::new(EXECUTION) + &RunContext::new(EXECUTION) .limits( RunLimits::new() .max_fanout_concurrency(NonZeroUsize::new(1).expect("1 is non-zero")), @@ -3145,7 +3145,7 @@ async fn an_answer_for_an_unknown_request_id_fails_loudly() { /// Arms the run's shared tool set with `bindings`, every alias in the /// prompt-wide `always` scope, so a section's effective scope carries them /// without an H1 pass. -fn arm_tool_set(ctx: &RunContext, bindings: Vec) { +fn arm_tool_set(ctx: &RunState, bindings: Vec) { let always = bindings .iter() .map(|binding| binding.alias().to_owned()) @@ -3157,7 +3157,7 @@ fn arm_tool_set(ctx: &RunContext, bindings: Vec) { /// the prompt-wide scope, so a binding can sit in the document catalog /// without entering any section's effective scope. fn arm_tool_set_scoped( - ctx: &RunContext, + ctx: &RunState, bindings: Vec, always: Vec, ) { diff --git a/crates/promptforge-api/src/lib.rs b/crates/promptforge-api/src/lib.rs index dcfabd39..beba1ba1 100644 --- a/crates/promptforge-api/src/lib.rs +++ b/crates/promptforge-api/src/lib.rs @@ -11,12 +11,12 @@ //! (`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 +//! [`execute::run`] takes an [`execute::RunContext`] 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. +//! context; production hosts leave it unset. //! //! A source is a promptforge prompt only when its frontmatter declares a //! `promptforge:` version; [`promptforge_version`] reports it (or `None`), and @@ -42,33 +42,28 @@ //! # Ok::<(), promptforge_api::ParseError>(()) //! ``` //! -//! 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`: +//! Executing a parsed prompt goes through [`run`] with a [`RunContext`] +//! built from an [`Environment`] (which holds the optional picker, the +//! model catalog, and the tool catalog); the store handle rides on the +//! context, 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_api::{Prompt, ResolutionContext, RunConfig, run}; -//! use shared_promptforge_api::models::ModelCatalog; +//! use promptforge_api::{Environment, Prompt, RunContext, RunResult}; //! 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())?; //! -//! // Capability-free agents pass no picker. -//! let models = ModelCatalog::empty(); -//! let tools = ToolCatalog::new(&[])?; -//! let answer = run( -//! &prompt, -//! "", -//! ResolutionContext::new(None, &models, &tools), -//! RunConfig::new("run-example"), -//! ) -//! .await?; -//! println!("{answer}"); +//! // Capability-free agents use the default environment: no picker, empty +//! // catalogs. +//! let env = Environment::new(); +//! let answer = env.run(&prompt, "", RunContext::new("run-example")).await; +//! let RunResult::Ok(text) = answer else { +//! panic!("the greeter run succeeds: {answer:?}"); +//! }; +//! println!("{text}"); //! # Ok(()) //! # } //! ``` @@ -96,5 +91,7 @@ pub(crate) use crate::error::{Error, Result}; pub(crate) use crate::tools::NearDuplicateDiagnostic; pub use crate::client::{CompletionError, CompletionErrorKind}; -pub use crate::execute::{ResolutionContext, RunConfig, RunError, RunErrorKind, RunLimits, run}; +pub use crate::execute::{ + Environment, RunContext, RunError, RunErrorKind, RunLimits, RunResult, run, +}; pub use crate::parser::{ParseError, ParseErrorKind, Prompt, promptforge_version}; diff --git a/crates/promptforge-api/tests/suite/support.rs b/crates/promptforge-api/tests/suite/support.rs index a9026c61..2c46f258 100644 --- a/crates/promptforge-api/tests/suite/support.rs +++ b/crates/promptforge-api/tests/suite/support.rs @@ -5,11 +5,10 @@ use std::sync::{Arc, Mutex}; -use promptforge_api::execute::{ResolutionContext, RunConfig, RunError, run as run_core}; +use promptforge_api::execute::{Environment, RunContext, RunError, RunResult}; use promptforge_api::parser::Prompt; use promptforge_store::{StoreError, StoreExt}; use promptforge_tool_picker::{Catalog, Config, ToolPicker}; -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}; @@ -34,8 +33,8 @@ impl Record { } } -/// Owned run inputs a fixture supplies: the execution id and an `Arc` observer -/// so the offline `run` helper can build a [`RunConfig`]. These fixtures never +/// Owned run inputs a fixture supplies: the run name and an `Arc` observer +/// so the offline `run` helper can build a [`RunContext`]. These fixtures never /// reach a model, so no client or debug sink is configured. pub(super) struct RunOptions { pub(super) execution: &'static str, @@ -56,17 +55,16 @@ pub(super) async fn run( None, ) .expect("empty fixture picker must build"); - let models = ModelCatalog::empty(); let tools = ToolCatalog::new(tools).expect("fixture tools are unique"); - run_core( - prompt, - args, - ResolutionContext::new(Some(&picker), &models, &tools), - RunConfig::new(opts.execution) - .observer(opts.observer) - .vfs(vfs.clone()), - ) - .await + let env = Environment::new().picker(picker).tools(tools); + let ctx = RunContext::new(opts.execution) + .observer(opts.observer) + .vfs(vfs.clone()); + match env.run(prompt, args, ctx).await { + RunResult::Ok(text) => Ok(text), + RunResult::Cancelled => panic!("offline fixture runs are never cancelled"), + RunResult::Failure(error) => Err(error), + } } /// A synchronized observer shared by concurrent fixture runs. diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index 0da88324..2f006035 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -28,13 +28,10 @@ use serde_json::json; use tokio::sync::broadcast; use promptforge_api::client::{GatewayClient as ModelClient, GatewayEndpoint, SecretString}; -use promptforge_api::execute::RunErrorKind; -use promptforge_api::{Prompt, ResolutionContext, RunConfig}; +use promptforge_api::{Environment, Prompt, RunContext, RunResult}; 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, @@ -346,7 +343,8 @@ fn spawn_restored_chat( ); let cancel = CancelHandle::new(); let observer: Arc = restored.clone(); - let config = RunConfig::new(session.to_owned()) + let env = Environment::new(); + let ctx = RunContext::new(session.to_owned()) .observer(Arc::clone(&observer)) .client(client) .cancel(cancel.clone()) @@ -359,23 +357,13 @@ fn spawn_restored_chat( let result = async { let prompt = Prompt::parse(CHAT_MD, &execution, observer.as_ref()) .expect("the embedded chat prompt parses"); - let models = ModelCatalog::empty(); - let tools = ToolCatalog::default(); - promptforge_api::run( - &prompt, - "", - ResolutionContext::new(None, &models, &tools), - config, - ) - .await + env.run(&prompt, "", ctx).await } .await; match result { - Ok(_output) => Ok(()), - Err(error) if matches!(error.kind(), RunErrorKind::Cancelled) => { - Err(AgentError::Interrupted) - } - Err(error) => Err(AgentError::Program { + RunResult::Ok(_output) => Ok(()), + RunResult::Cancelled => Err(AgentError::Interrupted), + RunResult::Failure(error) => Err(AgentError::Program { message: error.to_string(), }), } diff --git a/crates/workshop-sessions/src/agents/supervisor/effects.rs b/crates/workshop-sessions/src/agents/supervisor/effects.rs index 98f7e6cf..d67500de 100644 --- a/crates/workshop-sessions/src/agents/supervisor/effects.rs +++ b/crates/workshop-sessions/src/agents/supervisor/effects.rs @@ -3,11 +3,8 @@ use std::sync::Arc; 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 promptforge_api::{Environment, Prompt, RunContext, RunResult}; use shared_promptforge_api::observe::Observer; -use shared_promptforge_api::tools::ToolCatalog; use shared_promptforge_api::wire::StreamDelta; use workshop_gateway::GatewaySnapshot; @@ -137,30 +134,22 @@ async fn run_markdown_agent( Arc::clone(&session.waits), session.input_frames.clone(), )); - let models = ModelCatalog::empty(); - let tools = ToolCatalog::default(); - let config = RunConfig::new(session.id.clone()) + let env = Environment::new(); + let ctx = RunContext::new(session.id.clone()) .observer(observer) .client(client) .cancel(session.arm_cancel(run)) .input_broker(broker) .ui(ui) .on_delta(on_delta); - promptforge_api::run( - &prompt, - "", - ResolutionContext::new(None, &models, &tools), - config, - ) - .await - .map(|_output| ()) - .map_err(|error| match error.kind() { - RunErrorKind::Cancelled => AgentRunError::Interrupted, - _ => AgentRunError::Failed { + match env.run(&prompt, "", ctx).await { + RunResult::Ok(_output) => Ok(()), + RunResult::Cancelled => Err(AgentRunError::Interrupted), + RunResult::Failure(error) => Err(AgentRunError::Failed { message: error.to_string(), source: Some(Box::new(error)), - }, - }) + }), + } } /// Mutable runtime bindings and the currently executing run. diff --git a/crates/workshop-sessions/src/agents/tests.rs b/crates/workshop-sessions/src/agents/tests.rs index 9193a5fc..361cc380 100644 --- a/crates/workshop-sessions/src/agents/tests.rs +++ b/crates/workshop-sessions/src/agents/tests.rs @@ -1,7 +1,6 @@ use std::sync::atomic::AtomicU64; use shared_promptforge_api::events::RuntimeEventKind; -use shared_promptforge_api::models::ModelCatalog; use shared_promptforge_api::observe::{Observation, Observer}; use workshop_protocol::Activity; @@ -243,23 +242,20 @@ fn the_model_client_requires_a_usable_key_and_url() { async fn run_builtin_chat( broker: Option>, ) -> Result { - use promptforge_api::{Prompt, ResolutionContext, RunConfig}; + use promptforge_api::{Environment, Prompt, RunContext, RunResult}; 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 models = ModelCatalog::empty(); - let tools = shared_promptforge_api::tools::ToolCatalog::default(); - let mut config = RunConfig::new("chat-unit").observer(observer); + let env = Environment::new(); + let mut ctx = RunContext::new("chat-unit").observer(observer); if let Some(broker) = broker { - config = config.input_broker(broker); + ctx = ctx.input_broker(broker); + } + match env.run(&prompt, "", ctx).await { + RunResult::Ok(text) => Ok(text), + RunResult::Cancelled => panic!("the chat unit run is never cancelled"), + RunResult::Failure(error) => Err(error), } - promptforge_api::run( - &prompt, - "", - ResolutionContext::new(None, &models, &tools), - config, - ) - .await } #[tokio::test] diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 063e9e7a..0a4a1ad7 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -896,7 +896,7 @@ Migrate the picker's own 2-part `ToolId` (`promptforge-tool-picker/src/catalog.r -### Step 4: Interface consolidation (parity gate) +### Step 4: Interface consolidation (parity gate) [completed] - Component: interface From caad802bbefc3e03ee3d1a2fe09a0fce43ea78d8 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 20:30:20 -0700 Subject: [PATCH 05/30] Add capabilities, tools, args, models frontmatter keys The parser now accepts four frontmatter keys that declare a prompt's contract: the capabilities it activates, the tool slots it binds, its typed args, and its model roles. Parsing validates only the static shape - the alias grammar on map keys, capability id arity, the closed model keyword vocabulary, and each arg default against its declared type - and exposes the full declaration on the parsed prompt, because satisfying the declaration against the host environment is prepare's job, never the parser's. A prompt that declares no args key still gets a typed declaration, so there are no freeform prompts. - `crates/promptforge-parser/src/contract.rs` owns the capability and tool-slot shapes plus the map deserializer the contract keys share; args and models live in submodules and are re-exported so consumers see one surface. - `deserialize_contract_map` walks the map as a streaming visitor so rejections keep their source position; it enforces the alias grammar, rejects duplicate keys, and rejects a reserved key whose posture is deferred. - `CapabilityDecl` and `ToolSlot` deserialize through streaming visitors rather than untagged buffers, so a bad entry fails at its own position; each accepts a bare string or a map form. - `Frontmatter` gains the four keys behind public accessors; absent keys yield empty declarations, except args, which yields the default declaration. - `ArgsDecl` defaults to one optional string field named `prose` when the key is absent, so every prompt carries a typed args declaration. - `parse_capability_id` requires exactly two segments, and a version pin never gets that far because the charset rejects the at sign. - `ModelKeyword` is a closed vocabulary: an unknown keyword is a parse error, and a zero context minimum is rejected by the nonzero type. - `ArgDecl` rejects a declared default whose value does not match the declared type. - `ToolSlots` rejects the reserved `open` alias at parse, so a prompt cannot silently half-declare the deferred open toolset posture. - `ParseErrorKind::Frontmatter` is the only signal the contract rejections carry; structured source locations are not captured yet. Design: new facade @ crates/promptforge-parser/src/contract.rs Design: new pure-function @ crates/promptforge-parser/src/contract.rs::is_valid_alias deps: &str Design: new pure-function @ crates/promptforge-parser/src/contract.rs::parse_capability_id deps: &str Design: new pure-function @ crates/promptforge-parser/src/contract.rs::deserialize_contract_map deps: &'static str,D,Option<&'static str> Design: new encapsulated-invariant @ crates/promptforge-parser/src/contract.rs::CapabilityDecl boundary: pub Design: new encapsulated-invariant @ crates/promptforge-parser/src/contract.rs::FuzzySlot boundary: pub Design: new value-object @ crates/promptforge-parser/src/contract.rs::ToolSlot boundary: pub Design: new encapsulated-invariant @ crates/promptforge-parser/src/contract.rs::ToolSlots boundary: pub Design: new encapsulated-invariant @ crates/promptforge-parser/src/contract/args.rs::ArgDecl boundary: pub Design: new value-object @ crates/promptforge-parser/src/contract/args.rs::ArgType boundary: pub Design: new encapsulated-invariant @ crates/promptforge-parser/src/contract/args.rs::ArgsDecl boundary: pub Design: new encapsulated-invariant @ crates/promptforge-parser/src/contract/models.rs::ModelRole boundary: pub Design: new value-object @ crates/promptforge-parser/src/contract/models.rs::ModelKeyword boundary: pub Design: new encapsulated-invariant @ crates/promptforge-parser/src/contract/models.rs::ModelRoles boundary: pub Design: new surface-growth @ crates/promptforge-parser/src/build.rs::Frontmatter boundary: pub Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-parser/src/build.rs | 40 ++ crates/promptforge-parser/src/contract.rs | 407 ++++++++++++++++++ .../promptforge-parser/src/contract/args.rs | 190 ++++++++ .../promptforge-parser/src/contract/models.rs | 121 ++++++ .../promptforge-parser/src/contract/tests.rs | 367 ++++++++++++++++ crates/promptforge-parser/src/lib.rs | 5 + ...2026-09-13-1-capabilities-global-naming.md | 2 +- 7 files changed, 1131 insertions(+), 1 deletion(-) create mode 100644 crates/promptforge-parser/src/contract.rs create mode 100644 crates/promptforge-parser/src/contract/args.rs create mode 100644 crates/promptforge-parser/src/contract/models.rs create mode 100644 crates/promptforge-parser/src/contract/tests.rs diff --git a/crates/promptforge-parser/src/build.rs b/crates/promptforge-parser/src/build.rs index 852d481e..53621397 100644 --- a/crates/promptforge-parser/src/build.rs +++ b/crates/promptforge-parser/src/build.rs @@ -11,6 +11,7 @@ use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use shared_promptforge_api::observe::Observer; +use super::contract::{ArgsDecl, CapabilityDecl, ModelRoles, ToolSlots}; use super::fence::{RawBlock, lua_block_location, split_section_blocks}; use super::list::{is_all_list_markers, parse_bullet_items}; use super::{Block, LuaProgram, ParseErrorKind, Section}; @@ -74,6 +75,19 @@ pub struct Frontmatter { /// A file the prompt will leave in the store when it finishes. #[serde(default)] pub(crate) output: Option, + /// Capabilities the prompt activates at prepare, in declaration order. + #[serde(default)] + pub(crate) capabilities: Vec, + /// Declared tool slots: alias to exact path or fuzzy `want`. + #[serde(default)] + pub(crate) tools: ToolSlots, + /// The typed args declaration; an absent `args:` key yields the default + /// declaration (one optional string field named `prose`). + #[serde(default)] + pub(crate) args: ArgsDecl, + /// Declared model roles: label to keywords, minimum, and description. + #[serde(default)] + pub(crate) models: ModelRoles, } /// The largest explicit `max_tool_iterations` a prompt may declare. @@ -200,6 +214,32 @@ impl Frontmatter { pub fn output(&self) -> Option<&FileDecl> { self.output.as_ref() } + + /// Returns the declared capabilities, in declaration order. + #[must_use] + pub fn capabilities(&self) -> &[CapabilityDecl] { + &self.capabilities + } + + /// Returns the declared tool slots (alias to exact path or fuzzy `want`). + #[must_use] + pub fn tools(&self) -> &ToolSlots { + &self.tools + } + + /// Returns the typed args declaration. A prompt with no `args:` key + /// yields the default declaration (one optional string field named + /// `prose`). + #[must_use] + pub fn args(&self) -> &ArgsDecl { + &self.args + } + + /// Returns the declared model roles (label to role). + #[must_use] + pub fn models(&self) -> &ModelRoles { + &self.models + } } /// A heading with its title and the prose/Lua that follows it (before the next /// heading of any level). diff --git a/crates/promptforge-parser/src/contract.rs b/crates/promptforge-parser/src/contract.rs new file mode 100644 index 00000000..6ec86fec --- /dev/null +++ b/crates/promptforge-parser/src/contract.rs @@ -0,0 +1,407 @@ +//! The frontmatter contract keys: `capabilities`, `tools`, `args`, `models`. +//! +//! The YAML is the whole contract: capabilities install, tools bind, models +//! declare, args type. Parsing validates the static shape - capability id +//! arity, the alias grammar on slot keys, the closed model-keyword +//! vocabulary, arg name and type sanity - and exposes the FULL declaration +//! on the parsed [`Prompt`](crate::Prompt); satisfying the declaration +//! against the host environment is prepare's job, never the parser's. +//! +//! `args` and `models` live in submodules; this root owns the capability +//! and tool-slot shapes plus the map deserializer all four keys share. + +use std::collections::BTreeMap; +use std::fmt; +use std::marker::PhantomData; + +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; + +use shared_promptforge_api::names::GlobalName; +use shared_promptforge_api::tools::ToolId; + +mod args; +mod models; + +#[cfg(test)] +mod tests; + +pub use args::{ArgDecl, ArgType, ArgsDecl}; +pub use models::{ModelKeyword, ModelRole, ModelRoles}; + +/// The prompt-local alias grammar: `[A-Za-z][A-Za-z0-9_-]{0,63}`. +/// +/// Aliases are the only names a model ever sees - tool slot aliases, model +/// labels, and args field names are all prompt-local and never global +/// names. (The same rule lives in `promptforge-lua`'s live binding and +/// model decode paths; the survey's consolidation note applies when those +/// files are touched.) +fn is_valid_alias(alias: &str) -> bool { + let bytes = alias.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphabetic() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +/// Deserializes a contract map (`tools`, `models`, `args`): string keys +/// validated against the alias grammar, values deserialized as `T`, +/// duplicates rejected. +/// +/// `what` names the key kind in error messages ("tool alias", "model role +/// label", "arg name"); `reserved` names a key that satisfies the grammar +/// but is rejected because its posture is deferred (the open toolset's +/// `open`). +pub(crate) fn deserialize_contract_map<'de, D, T>( + deserializer: D, + what: &'static str, + reserved: Option<&'static str>, +) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + deserializer.deserialize_map(MapVisitor { + what, + reserved, + _marker: PhantomData, + }) +} + +/// The visitor behind [`deserialize_contract_map`]: a streaming map walk so +/// rejections keep their source position. +struct MapVisitor { + /// The key kind, for error messages. + what: &'static str, + /// A grammatically valid key that is rejected as reserved. + reserved: Option<&'static str>, + /// The value type, without ownership or variance claims. + _marker: PhantomData T>, +} + +impl<'de, T> Visitor<'de> for MapVisitor +where + T: Deserialize<'de>, +{ + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a map of {} keys to declarations", self.what) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut entries: BTreeMap = BTreeMap::new(); + while let Some(key) = map.next_key::()? { + if self.reserved == Some(key.as_str()) { + return Err(de::Error::custom(format!( + "the `{key}` key is reserved for the deferred open toolset posture; it is not a usable {}", + self.what + ))); + } + if !is_valid_alias(&key) { + return Err(de::Error::custom(format!( + "invalid {} `{key}`: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}", + self.what + ))); + } + if entries.contains_key(&key) { + return Err(de::Error::custom(format!( + "duplicate {} `{key}`: contract map keys must be unique", + self.what + ))); + } + entries.insert(key, map.next_value::()?); + } + Ok(entries) + } +} + +/// Parses a capability id: a [`GlobalName`] of exactly two segments +/// (`namespace/pack`). +/// +/// The grammar accepts two or three segments, so the arity check counts +/// separators: exactly one `/` is two segments. A `@` version pin never +/// gets that far - the charset rejects it (v1 is unversioned). +fn parse_capability_id(text: &str) -> Result { + let name = GlobalName::parse(text) + .map_err(|error| format!("invalid capability id `{text}`: {error}"))?; + if text.matches('/').count() != 1 { + return Err(format!( + "invalid capability id `{text}`: a capability id has exactly 2 segments (namespace/pack)" + )); + } + Ok(name) +} + +/// A capability declaration: a plain id string (a required capability) or a +/// `ref` map carrying the `optional` flag and prompt-side `config` data. +/// +/// User-specific configuration (credentials, server lists) is host-supplied +/// through the run services and never named in the prompt; `config` is +/// prompt-side data only. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct CapabilityDecl { + /// The capability's global id (`namespace/pack`, exactly 2 segments). + id: GlobalName, + /// Whether an absent capability skips with a log line instead of + /// failing preparation. + optional: bool, + /// Prompt-side configuration data, when declared. + config: Option, +} + +impl CapabilityDecl { + /// Returns the capability's global id (`namespace/pack`). + #[must_use] + pub fn id(&self) -> &GlobalName { + &self.id + } + + /// Returns whether the capability is optional (skip-and-log when + /// absent). + #[must_use] + pub fn is_optional(&self) -> bool { + self.optional + } + + /// Returns the prompt-side configuration data, when declared. + #[must_use] + pub fn config(&self) -> Option<&serde_yaml_ng::Value> { + self.config.as_ref() + } +} + +impl<'de> Deserialize<'de> for CapabilityDecl { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(CapabilityDeclVisitor) + } +} + +/// Deserializes a capability declaration from either frontmatter form: a +/// bare id string or a `ref` map. A streaming visitor (not an untagged +/// buffer) so rejections keep their source position. +struct CapabilityDeclVisitor; + +impl<'de> Visitor<'de> for CapabilityDeclVisitor { + type Value = CapabilityDecl; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a capability id string or a map with `ref`, `optional`, and `config`") + } + + fn visit_str(self, text: &str) -> Result + where + E: de::Error, + { + Ok(CapabilityDecl { + id: parse_capability_id(text).map_err(E::custom)?, + optional: false, + config: None, + }) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut reference: Option = None; + let mut optional: Option = None; + let mut config: Option = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "ref" => { + if reference.is_some() { + return Err(de::Error::duplicate_field("ref")); + } + reference = Some(map.next_value()?); + } + "optional" => { + if optional.is_some() { + return Err(de::Error::duplicate_field("optional")); + } + optional = Some(map.next_value()?); + } + "config" => { + if config.is_some() { + return Err(de::Error::duplicate_field("config")); + } + config = Some(map.next_value()?); + } + other => { + return Err(de::Error::unknown_field( + other, + &["ref", "optional", "config"], + )); + } + } + } + let reference = reference.ok_or_else(|| de::Error::missing_field("ref"))?; + Ok(CapabilityDecl { + id: parse_capability_id(&reference).map_err(de::Error::custom)?, + optional: optional.unwrap_or(false), + config, + }) + } +} + +/// One tool slot's filling posture: an exact global path filled by identity +/// against the assembled catalog, or a fuzzy `want` description filled by +/// the picker at prepare (every fill is journaled). +/// +/// `#[non_exhaustive]`: the open host-offered posture is deferred and joins +/// this enum when it lands. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ToolSlot { + /// An exact global tool path, filled by identity against the catalog. + Exact(ToolId), + /// A fuzzy slot, filled by the picker at prepare. + Fuzzy(FuzzySlot), +} + +/// A fuzzy tool slot: a prose `want` description the picker matches against +/// the catalog at prepare, plus optionality. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct FuzzySlot { + /// The prose description the picker matches against the catalog. + want: String, + /// Whether an unfillable slot skips with a log line instead of failing + /// preparation. + optional: bool, +} + +impl FuzzySlot { + /// Returns the prose description the picker matches. + #[must_use] + pub fn want(&self) -> &str { + &self.want + } + + /// Returns whether an unfillable slot skips with a log line instead of + /// failing preparation. + #[must_use] + pub fn is_optional(&self) -> bool { + self.optional + } +} + +impl<'de> Deserialize<'de> for ToolSlot { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(ToolSlotVisitor) + } +} + +/// Deserializes a tool slot: a bare string is an exact path, a map is a +/// fuzzy slot. +struct ToolSlotVisitor; + +impl<'de> Visitor<'de> for ToolSlotVisitor { + type Value = ToolSlot; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an exact tool path string or a map with `want` and `optional`") + } + + fn visit_str(self, text: &str) -> Result + where + E: de::Error, + { + let id = ToolId::parse(text) + .map_err(|error| E::custom(format!("invalid exact tool path `{text}`: {error}")))?; + Ok(ToolSlot::Exact(id)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut want: Option = None; + let mut optional: Option = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "want" => { + if want.is_some() { + return Err(de::Error::duplicate_field("want")); + } + want = Some(map.next_value()?); + } + "optional" => { + if optional.is_some() { + return Err(de::Error::duplicate_field("optional")); + } + optional = Some(map.next_value()?); + } + other => { + return Err(de::Error::unknown_field(other, &["want", "optional"])); + } + } + } + let want = want.ok_or_else(|| de::Error::missing_field("want"))?; + Ok(ToolSlot::Fuzzy(FuzzySlot { + want, + optional: optional.unwrap_or(false), + })) + } +} + +/// The prompt's declared tool slots: alias to slot. +/// +/// Aliases are prompt-local (the alias grammar); the model only ever sees +/// the alias, never the global path. The reserved `open` key (the deferred +/// open toolset posture) is rejected at parse, so a prompt cannot silently +/// half-declare the posture. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct ToolSlots { + /// The slots by alias. + slots: BTreeMap, +} + +impl ToolSlots { + /// Returns the slot declared under `alias`, when present. + #[must_use] + pub fn get(&self, alias: &str) -> Option<&ToolSlot> { + self.slots.get(alias) + } + + /// Iterates the declared slots as `(alias, slot)` pairs. + pub fn iter(&self) -> impl Iterator { + self.slots + .iter() + .map(|(alias, slot)| (alias.as_str(), slot)) + } + + /// Returns the number of declared slots. + #[must_use] + pub fn len(&self) -> usize { + self.slots.len() + } + + /// Returns whether no slots are declared. + #[must_use] + pub fn is_empty(&self) -> bool { + self.slots.is_empty() + } +} + +impl<'de> Deserialize<'de> for ToolSlots { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let slots = deserialize_contract_map(deserializer, "tool alias", Some("open"))?; + Ok(ToolSlots { slots }) + } +} diff --git a/crates/promptforge-parser/src/contract/args.rs b/crates/promptforge-parser/src/contract/args.rs new file mode 100644 index 00000000..db0791a9 --- /dev/null +++ b/crates/promptforge-parser/src/contract/args.rs @@ -0,0 +1,190 @@ +//! The `args` frontmatter key: typed arg declarations. +//! +//! The declaration advertises, documents, and derives the tool schema; it +//! does not enforce - enforcement belongs to the prompt's H1. There are no +//! freeform prompts: a prompt with no `args:` key gets the default +//! declaration of one optional string field named `prose`. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use super::deserialize_contract_map; + +/// The closed set of declared arg types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ArgType { + /// A string arg. + String, + /// A boolean arg. + Boolean, + /// An integer arg. + Integer, + /// A numeric arg. + Number, +} + +impl std::fmt::Display for ArgType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ArgType::String => "string", + ArgType::Boolean => "boolean", + ArgType::Integer => "integer", + ArgType::Number => "number", + }) + } +} + +/// One declared arg: its type, optionality, default, and description. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ArgDecl { + kind: ArgType, + optional: bool, + default: Option, + description: Option, +} + +impl ArgDecl { + /// Returns the declared type. + #[must_use] + pub fn kind(&self) -> ArgType { + self.kind + } + + /// Returns whether a call may omit the field entirely. Optional means + /// absent, and absent is not the empty string. + #[must_use] + pub fn is_optional(&self) -> bool { + self.optional + } + + /// Returns the declared default, when present. + #[must_use] + pub fn default(&self) -> Option<&serde_yaml_ng::Value> { + self.default.as_ref() + } + + /// Returns the human-readable description, when present. + #[must_use] + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } +} + +/// The map form of an arg declaration (`type` is a Rust keyword). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ArgDeclWire { + /// The declared type. + #[serde(rename = "type")] + kind: ArgType, + /// Whether a call may omit the field entirely. + #[serde(default)] + optional: bool, + /// The declared default value. + #[serde(default)] + default: Option, + /// The human-readable description. + #[serde(default)] + description: Option, +} + +/// Type sanity: a declared default must match the declared type. +fn default_matches(kind: ArgType, value: &serde_yaml_ng::Value) -> bool { + match (kind, value) { + (ArgType::String, serde_yaml_ng::Value::String(_)) + | (ArgType::Boolean, serde_yaml_ng::Value::Bool(_)) + | (ArgType::Number, serde_yaml_ng::Value::Number(_)) => true, + (ArgType::Integer, serde_yaml_ng::Value::Number(n)) => n.is_i64() || n.is_u64(), + _ => false, + } +} + +impl<'de> Deserialize<'de> for ArgDecl { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let wire = ArgDeclWire::deserialize(deserializer)?; + if let Some(default) = &wire.default + && !default_matches(wire.kind, default) + { + return Err(serde::de::Error::custom(format!( + "the default does not match the declared type `{}`", + wire.kind + ))); + } + Ok(ArgDecl { + kind: wire.kind, + optional: wire.optional, + default: wire.default, + description: wire.description, + }) + } +} + +/// A prompt's typed args declaration: arg name to declaration. +/// +/// There are no freeform prompts: a prompt with no `args:` key gets the +/// default declaration of one optional string field named `prose`, and prose +/// at the interface wraps into `argv = { prose = "" }`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ArgsDecl { + fields: BTreeMap, +} + +impl ArgsDecl { + /// Returns the declaration of the arg named `name`, when present. + #[must_use] + pub fn get(&self, name: &str) -> Option<&ArgDecl> { + self.fields.get(name) + } + + /// Iterates the declared args as `(name, declaration)` pairs. + pub fn iter(&self) -> impl Iterator { + self.fields.iter().map(|(name, decl)| (name.as_str(), decl)) + } + + /// Returns the number of declared args. + #[must_use] + pub fn len(&self) -> usize { + self.fields.len() + } + + /// Returns whether no args are declared. + #[must_use] + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } +} + +impl Default for ArgsDecl { + /// The default declaration: one optional string field named `prose`. + fn default() -> Self { + let mut fields = BTreeMap::new(); + fields.insert( + "prose".to_owned(), + ArgDecl { + kind: ArgType::String, + optional: true, + default: None, + description: Some("Freeform input for this prompt".to_owned()), + }, + ); + ArgsDecl { fields } + } +} + +impl<'de> Deserialize<'de> for ArgsDecl { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let fields = deserialize_contract_map(deserializer, "arg name", None)?; + Ok(ArgsDecl { fields }) + } +} diff --git a/crates/promptforge-parser/src/contract/models.rs b/crates/promptforge-parser/src/contract/models.rs new file mode 100644 index 00000000..cb3b74b3 --- /dev/null +++ b/crates/promptforge-parser/src/contract/models.rs @@ -0,0 +1,121 @@ +//! The `models` frontmatter key: declared model roles. +//! +//! A role is a slot: a fill function at prepare maps it to a concrete model +//! and checks the hard keywords and the context minimum against the filled +//! descriptor. Parse only validates shape and exposes the declaration. + +use std::collections::BTreeMap; +use std::num::NonZeroU32; + +use serde::Deserialize; + +use super::deserialize_contract_map; + +/// The closed model-keyword vocabulary. +/// +/// Hard keywords (`thinking`, `no-thinking`) and the context minimum are +/// checked per slot against the filled model's descriptor at prepare; soft +/// keywords document author intent. Unknown keywords are parse errors; +/// adding a keyword is a language change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum ModelKeyword { + /// The model supports extended thinking (hard). + Thinking, + /// The model never thinks (hard). + NoThinking, + /// A frontier-capability model (soft). + Frontier, + /// A fast model (soft). + Fast, + /// A small model (soft). + Small, + /// A creative model (soft). + Creative, + /// A chat-tuned model (soft). + Chat, +} + +/// One declared model role: keywords, a context minimum, and a description. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct ModelRole { + /// The declared keywords (closed vocabulary). + #[serde(default)] + keywords: Vec, + /// The minimum context window in tokens. + #[serde(default)] + min_context: Option, + /// The role's prose description. + #[serde(default)] + description: Option, +} + +impl ModelRole { + /// Returns the declared keywords. + #[must_use] + pub fn keywords(&self) -> &[ModelKeyword] { + &self.keywords + } + + /// Returns the context minimum in tokens, when declared. + #[must_use] + pub fn min_context(&self) -> Option { + self.min_context + } + + /// Returns the role's description, when declared. + #[must_use] + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } +} + +/// The prompt's declared model roles: label to role. +/// +/// Labels are prompt-local (the alias grammar); the model never sees a +/// concrete model id in the declaration. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct ModelRoles { + roles: BTreeMap, +} + +impl ModelRoles { + /// Returns the role declared under `label`, when present. + #[must_use] + pub fn get(&self, label: &str) -> Option<&ModelRole> { + self.roles.get(label) + } + + /// Iterates the declared roles as `(label, role)` pairs. + pub fn iter(&self) -> impl Iterator { + self.roles + .iter() + .map(|(label, role)| (label.as_str(), role)) + } + + /// Returns the number of declared roles. + #[must_use] + pub fn len(&self) -> usize { + self.roles.len() + } + + /// Returns whether no roles are declared. + #[must_use] + pub fn is_empty(&self) -> bool { + self.roles.is_empty() + } +} + +impl<'de> Deserialize<'de> for ModelRoles { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let roles = deserialize_contract_map(deserializer, "model role label", None)?; + Ok(ModelRoles { roles }) + } +} diff --git a/crates/promptforge-parser/src/contract/tests.rs b/crates/promptforge-parser/src/contract/tests.rs new file mode 100644 index 00000000..585d4314 --- /dev/null +++ b/crates/promptforge-parser/src/contract/tests.rs @@ -0,0 +1,367 @@ +//! Tests for the frontmatter contract keys: `capabilities`, `tools`, +//! `args`, and `models` (the frontmatter matrix from the plan's Testing +//! Plan; structured error locations are a later step). + +use std::num::NonZeroU32; + +use shared_promptforge_api::observe::NullObserver; + +use super::{ArgType, ModelKeyword, ToolSlot}; +use crate::{ParseError, ParseErrorKind, Prompt}; + +fn parse(yaml: &str) -> Result { + let src = format!("---\n{yaml}---\n\n# T\n\n## S\n\np\n"); + Prompt::parse(&src, "test", &NullObserver::default()) +} + +#[test] +fn the_full_contract_declaration_parses_and_round_trips() { + let prompt = parse(concat!( + "name: x\n", + "description: d\n", + "capabilities:\n", + " - promptforge/web\n", + " - ref: io.github.corp/mcp\n", + " optional: true\n", + " config:\n", + " servers: [alpha]\n", + "tools:\n", + " search: promptforge/web/search\n", + " fetch: promptforge/web/fetch\n", + " wiki:\n", + " want: searches private wikis\n", + " optional: true\n", + "args:\n", + " use_mcp:\n", + " type: boolean\n", + " default: true\n", + " description: Search MCP-connected private sources\n", + "models:\n", + " analyst:\n", + " keywords: [frontier, thinking]\n", + " min_context: 200000\n", + " description: deep reasoning\n", + " triage:\n", + " keywords: [fast, small]\n", + " description: quick triage of search results\n", + )) + .expect("the full contract matrix must parse"); + + let fm = prompt.frontmatter(); + + let caps = fm.capabilities(); + assert_eq!(caps.len(), 2); + assert_eq!(caps[0].id().to_string(), "promptforge/web"); + assert!(!caps[0].is_optional()); + assert!(caps[0].config().is_none()); + assert_eq!(caps[1].id().to_string(), "io.github.corp/mcp"); + assert!(caps[1].is_optional()); + let config = caps[1].config().expect("the detailed entry carries config"); + assert_eq!( + config["servers"], + serde_yaml_ng::Value::Sequence(vec![serde_yaml_ng::Value::String("alpha".to_owned())]) + ); + + let tools = fm.tools(); + assert_eq!(tools.len(), 3); + match tools.get("search") { + Some(ToolSlot::Exact(id)) => assert_eq!(id.to_string(), "promptforge/web/search"), + other => panic!("expected an exact slot, got {other:?}"), + } + match tools.get("fetch") { + Some(ToolSlot::Exact(id)) => assert_eq!(id.to_string(), "promptforge/web/fetch"), + other => panic!("expected an exact slot, got {other:?}"), + } + match tools.get("wiki") { + Some(ToolSlot::Fuzzy(slot)) => { + assert_eq!(slot.want(), "searches private wikis"); + assert!(slot.is_optional()); + } + other => panic!("expected a fuzzy slot, got {other:?}"), + } + + let arg = fm.args().get("use_mcp").expect("the arg is declared"); + assert_eq!(arg.kind(), ArgType::Boolean); + assert!(!arg.is_optional()); + assert_eq!(arg.default(), Some(&serde_yaml_ng::Value::Bool(true))); + assert_eq!( + arg.description(), + Some("Search MCP-connected private sources") + ); + + let analyst = fm.models().get("analyst").expect("the role is declared"); + assert_eq!( + analyst.keywords(), + &[ModelKeyword::Frontier, ModelKeyword::Thinking] + ); + assert_eq!(analyst.min_context(), NonZeroU32::new(200_000)); + assert_eq!(analyst.description(), Some("deep reasoning")); + let triage = fm.models().get("triage").expect("the role is declared"); + assert_eq!( + triage.keywords(), + &[ModelKeyword::Fast, ModelKeyword::Small] + ); + assert_eq!(triage.min_context(), None); +} + +#[test] +fn a_prompt_without_contract_keys_behaves_exactly_as_today() { + let prompt = parse("name: x\ndescription: d\n").expect("a plain prompt must parse"); + let fm = prompt.frontmatter(); + assert!(fm.capabilities().is_empty()); + assert!(fm.tools().is_empty()); + assert!(fm.models().is_empty()); +} + +#[test] +fn an_omitted_args_key_yields_the_default_prose_declaration() { + // There are no freeform prompts: an absent `args:` key is the default + // declaration of one optional string field named `prose`. + let prompt = parse("name: x\ndescription: d\n").expect("a plain prompt must parse"); + let args = prompt.frontmatter().args(); + assert_eq!(args.len(), 1); + let prose = args + .get("prose") + .expect("the default declaration names `prose`"); + assert_eq!(prose.kind(), ArgType::String); + assert!(prose.is_optional()); + assert_eq!(prose.default(), None); + assert_eq!(prose.description(), Some("Freeform input for this prompt")); +} + +#[test] +fn an_unknown_key_inside_a_contract_entry_is_rejected() { + // `deny_unknown_fields` must hold inside each new key's entries too: a + // typo'd field is an authoring error, not silently ignored. + for yaml in [ + "name: x\ndescription: d\ncapabilities:\n - ref: promptforge/web\n optionl: true\n", + "name: x\ndescription: d\ntools:\n wiki:\n wants: prose\n", + "name: x\ndescription: d\nargs:\n flag:\n tipe: boolean\n", + "name: x\ndescription: d\nmodels:\n analyst:\n keyword: [fast]\n", + ] { + let error = parse(yaml).expect_err("an unknown key inside an entry must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{error}"); + } +} + +#[test] +fn a_capability_id_must_have_exactly_two_segments() { + for id in ["web", "promptforge/web/fetch", "promptforge//web"] { + let yaml = format!("name: x\ndescription: d\ncapabilities:\n - {id}\n"); + let error = parse(&yaml).expect_err("a bad capability id must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{id}: {error}"); + } +} + +#[test] +fn an_at_sign_in_a_capability_id_is_rejected() { + // v1 is unversioned: version pins are deferred, so `@` is a parse error. + let error = parse("name: x\ndescription: d\ncapabilities:\n - promptforge/web@1\n") + .expect_err("a `@` version pin must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} + +#[test] +fn a_capability_id_with_uppercase_is_rejected() { + let error = parse("name: x\ndescription: d\ncapabilities:\n - Promptforge/web\n") + .expect_err("uppercase is outside the segment charset"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} + +#[test] +fn a_capability_is_required_unless_flagged_optional() { + let prompt = parse(concat!( + "name: x\ndescription: d\n", + "capabilities:\n", + " - promptforge/web\n", + " - ref: io.github.corp/mcp\n", + " optional: true\n", + )) + .expect("capability entries must parse"); + let caps = prompt.frontmatter().capabilities(); + assert_eq!(caps.len(), 2); + assert!(!caps[0].is_optional(), "a plain string entry is required"); + assert!(caps[1].is_optional()); +} + +#[test] +fn a_capability_entry_must_be_a_string_or_a_ref_map() { + let error = parse("name: x\ndescription: d\ncapabilities:\n - 42\n") + .expect_err("a numeric capability entry must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} + +#[test] +fn a_fuzzy_slot_is_required_unless_flagged_optional() { + let prompt = parse("name: x\ndescription: d\ntools:\n wiki:\n want: searches wikis\n") + .expect("a fuzzy slot must parse"); + match prompt.frontmatter().tools().get("wiki") { + Some(ToolSlot::Fuzzy(slot)) => { + assert_eq!(slot.want(), "searches wikis"); + assert!(!slot.is_optional()); + } + other => panic!("expected a fuzzy slot, got {other:?}"), + } +} + +#[test] +fn a_malformed_exact_tool_path_is_a_parse_error() { + for path in [ + "promptforge/web", + "web", + "promptforge/Web/fetch", + "promptforge/web/", + ] { + let yaml = format!("name: x\ndescription: d\ntools:\n search: {path}\n"); + let error = parse(&yaml).expect_err("a malformed exact path must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{path}: {error}"); + } +} + +#[test] +fn the_reserved_open_tool_slot_key_is_rejected() { + // The open host-offered posture is deferred, so `open` is reserved even + // though it satisfies the alias grammar. + let error = parse("name: x\ndescription: d\ntools:\n open: true\n") + .expect_err("the reserved `open` key must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert!( + error.to_string().contains("reserved"), + "the error must name the reservation: {error}" + ); +} + +#[test] +fn tool_slot_aliases_must_match_the_alias_grammar() { + for alias in ["1search", "has space", "has/slash", "has.dot"] { + let yaml = + format!("name: x\ndescription: d\ntools:\n '{alias}': promptforge/web/search\n"); + let error = parse(&yaml).expect_err("a bad alias must be rejected"); + assert_eq!( + error.kind(), + ParseErrorKind::Frontmatter, + "{alias}: {error}" + ); + } + // The length boundary: 64 characters pass, 65 fail. + let longest_ok = format!("a{}", "b".repeat(63)); + let too_long = format!("a{}", "b".repeat(64)); + let yaml = format!("name: x\ndescription: d\ntools:\n {longest_ok}: promptforge/web/search\n"); + parse(&yaml).expect("a 64-character alias must parse"); + let yaml = format!("name: x\ndescription: d\ntools:\n {too_long}: promptforge/web/search\n"); + parse(&yaml).expect_err("a 65-character alias must be rejected"); +} + +#[test] +fn args_declarations_round_trip() { + let prompt = parse(concat!( + "name: x\ndescription: d\n", + "args:\n", + " use_mcp:\n", + " type: boolean\n", + " default: true\n", + " description: Search private sources\n", + " limit:\n", + " type: integer\n", + " optional: true\n", + " query:\n", + " type: string\n", + )) + .expect("args declarations must parse"); + let args = prompt.frontmatter().args(); + assert_eq!(args.len(), 3); + let limit = args.get("limit").expect("declared"); + assert_eq!(limit.kind(), ArgType::Integer); + assert!(limit.is_optional()); + assert_eq!(limit.description(), None); + assert_eq!(args.get("query").expect("declared").kind(), ArgType::String); +} + +#[test] +fn an_arg_default_must_match_the_declared_type() { + for (kind, default) in [("boolean", "'true'"), ("integer", "1.5"), ("string", "42")] { + let yaml = format!( + "name: x\ndescription: d\nargs:\n flag:\n type: {kind}\n default: {default}\n" + ); + let error = parse(&yaml).expect_err("a mismatched default must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter, "{kind}: {error}"); + } + // A matching default passes. + let yaml = "name: x\ndescription: d\nargs:\n limit:\n type: integer\n default: 3\n"; + let prompt = parse(yaml).expect("a matching default must parse"); + assert_eq!( + prompt + .frontmatter() + .args() + .get("limit") + .and_then(|a| a.default().cloned()), + Some(serde_yaml_ng::Value::Number(3.into())) + ); +} + +#[test] +fn an_arg_name_must_match_the_alias_grammar() { + let yaml = "name: x\ndescription: d\nargs:\n '1bad':\n type: string\n"; + let error = parse(yaml).expect_err("a bad arg name must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} + +#[test] +fn an_unknown_arg_type_is_rejected() { + let yaml = "name: x\ndescription: d\nargs:\n flag:\n type: text\n"; + let error = parse(yaml).expect_err("an unknown arg type must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} + +#[test] +fn model_roles_round_trip_with_defaults() { + let prompt = parse(concat!( + "name: x\ndescription: d\n", + "models:\n", + " analyst:\n", + " keywords: [no-thinking, creative, chat]\n", + " min_context: 32000\n", + " description: deep reasoning\n", + " spare: {}\n", + )) + .expect("model roles must parse"); + let roles = prompt.frontmatter().models(); + assert_eq!(roles.len(), 2); + let analyst = roles.get("analyst").expect("declared"); + assert_eq!( + analyst.keywords(), + &[ + ModelKeyword::NoThinking, + ModelKeyword::Creative, + ModelKeyword::Chat + ] + ); + assert_eq!(analyst.min_context(), NonZeroU32::new(32_000)); + let spare = roles.get("spare").expect("declared"); + assert!(spare.keywords().is_empty()); + assert_eq!(spare.min_context(), None); + assert_eq!(spare.description(), None); +} + +#[test] +fn an_unknown_model_keyword_is_a_parse_error() { + // The keyword vocabulary is closed; `multimodal` is a non-goal and a + // typo must fail at parse rather than being silently ignored. + let error = parse("name: x\ndescription: d\nmodels:\n analyst:\n keywords: [multimodal]\n") + .expect_err("an unknown keyword must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} + +#[test] +fn model_role_labels_must_match_the_alias_grammar() { + let yaml = "name: x\ndescription: d\nmodels:\n '1analyst':\n keywords: [fast]\n"; + let error = parse(yaml).expect_err("a bad role label must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} + +#[test] +fn a_zero_min_context_is_rejected() { + let error = parse("name: x\ndescription: d\nmodels:\n analyst:\n min_context: 0\n") + .expect_err("a zero context minimum must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); +} diff --git a/crates/promptforge-parser/src/lib.rs b/crates/promptforge-parser/src/lib.rs index a9b22fdc..5aa92d3b 100644 --- a/crates/promptforge-parser/src/lib.rs +++ b/crates/promptforge-parser/src/lib.rs @@ -21,6 +21,7 @@ use shared_promptforge_api::observe::{Observer, detail}; pub use promptforge_lua::LuaProgram; mod build; +mod contract; mod fence; mod list; @@ -31,6 +32,10 @@ pub use build::{ FileDecl, Frontmatter, MAX_TOOL_ITERATIONS, MaxToolIterations, promptforge_version, }; use build::{Heading, build_sections, collect_headings, line_add, split_frontmatter}; +pub use contract::{ + ArgDecl, ArgType, ArgsDecl, CapabilityDecl, FuzzySlot, ModelKeyword, ModelRole, ModelRoles, + ToolSlot, ToolSlots, +}; use fence::{exact_shared_openings, split_h1}; /// A type-erased owned error cause used by the internal substrate. diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 0a4a1ad7..3ba6889c 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -906,7 +906,7 @@ Pure refactor, no behavior change: merge `ResolutionContext` and `RunConfig` int -### Step 5: Frontmatter contract keys +### Step 5: Frontmatter contract keys [completed] - Component: frontmatter From f2d0a2be797ca06fdd9255e0f6da1fb3912310f1 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 20:48:13 -0700 Subject: [PATCH 06/30] Surface structured locations on parse and internal errors Parse failures and internal invariant violations now carry structured source positions alongside their messages. A frontmatter failure surfaces the retained YAML error's line and column; a failure after the frontmatter also carries the prompt's name and the offending span's line and byte column. An internal fault captures the Rust source file and line of its construction site through a caller-tracking constructor. Hosts read one location accessor on the run error to navigate to any of these. - `SourceLocation` is one generic shape for both worlds: the prompt's frontmatter name or a placeholder as the path for parse failures, the Rust source file for internal faults, with optional line, column, and byte span. - `Error::Internal` becomes a non-exhaustive struct variant carrying the construction site's file and line, built only through the track_caller `Error::internal` constructor; every invariant site in the scheduler, engine, section context, and tool loop moves to it. - `Classification` aggregates what `classify_parse_error` extracts - kind, span, name, line, and column - replacing the tuple it returned. - `RunError::location` maps parse failures to their prompt position and internal faults to their Rust position, and returns None for kinds with no source position to navigate to. - `with_prompt_context` stamps the frontmatter name onto post-frontmatter failures and computes the span's 1-based line and byte column; frontmatter and Lua compile failures pass through unchanged. - `body_line_column` derives a file-absolute line and byte column from a byte offset, yielding None on an out-of-bounds offset or overflow so a broken invariant never replaces the original failure. - `with_prompt_name` stamps the already-parsed prompt's name onto the missing-version failure raised by run itself. - `Error::ParseFrontmatter` carries no prompt name, since the failure predates it; the location path is a placeholder the host replaces with its own label for the source. Design: new value-object @ crates/promptforge-api/src/execute/error.rs::SourceLocation boundary: pub Design: new pure-function @ crates/promptforge-parser/src/lib.rs::body_line_column deps: str,u32,usize Design: new surface-growth @ crates/promptforge-api/src/execute/error.rs::RunError::location boundary: pub Design: new surface-growth @ crates/promptforge-parser/src/lib.rs::ParseError::name boundary: pub Design: new surface-growth @ crates/promptforge-parser/src/lib.rs::ParseError::line boundary: pub Design: new surface-growth @ crates/promptforge-parser/src/lib.rs::ParseError::column boundary: pub Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/src/error.rs | 153 +++++++++++++- crates/promptforge-api/src/execute.rs | 13 +- crates/promptforge-api/src/execute/engine.rs | 2 +- crates/promptforge-api/src/execute/error.rs | 61 +++++- .../promptforge-api/src/execute/scheduler.rs | 82 ++++---- .../src/execute/section_context.rs | 8 +- .../src/execute/tests/exit_rules.rs | 12 +- .../src/execute/tests/scheduler.rs | 2 +- .../promptforge-api/src/execute/tool_loop.rs | 8 +- crates/promptforge-api/src/lib.rs | 2 +- crates/promptforge-parser/src/build.rs | 3 + .../promptforge-parser/src/contract/tests.rs | 34 +++ crates/promptforge-parser/src/lib.rs | 196 ++++++++++++++++-- crates/promptforge-parser/src/tests.rs | 46 ++++ ...2026-09-13-1-capabilities-global-naming.md | 2 +- 15 files changed, 541 insertions(+), 83 deletions(-) diff --git a/crates/promptforge-api/src/error.rs b/crates/promptforge-api/src/error.rs index c9c9b862..968ebcbe 100644 --- a/crates/promptforge-api/src/error.rs +++ b/crates/promptforge-api/src/error.rs @@ -52,6 +52,11 @@ pub(crate) enum Error { /// The originating YAML parse failure, kept as the cause. #[source] source: BoxedSource, + /// The 1-based file line of the YAML failure, surfaced from the + /// retained cause's location when it carries one. + line: Option, + /// The 1-based file column of the YAML failure, when known. + column: Option, }, /// A structurally-classified parse failure carrying a stable kind and an @@ -67,6 +72,13 @@ pub(crate) enum Error { span: Option<(usize, usize)>, /// The human-readable diagnostic. message: String, + /// The prompt's frontmatter name, when the failure postdates the + /// frontmatter (a frontmatter failure predates the name). + name: Option, + /// The 1-based file line of the span's start, when a span is known. + line: Option, + /// The 1-based byte column of the span's start, when a span is known. + column: Option, }, /// A required environment variable was missing. @@ -493,8 +505,20 @@ pub(crate) enum Error { /// has already guaranteed cannot occur). Surfaced as a concrete error rather /// than silently skipping work, so an impossible state cannot masquerade as a /// successful fall-through. - #[error("internal invariant violated: {0}")] - Internal(&'static str), + /// + /// The Rust source position of the construction site is captured (via + /// [`Error::internal`], which is `#[track_caller]`) so + /// [`crate::RunError::location`] can point at the broken invariant. + #[error("internal invariant violated: {message}")] + #[non_exhaustive] + Internal { + /// The violated invariant, as a noun phrase. + message: &'static str, + /// The Rust source file of the construction site (from `file!()`). + file: &'static str, + /// The 1-based line of the construction site (from `line!()`). + line: u32, + }, /// A Lua host resource quota (log events, log bytes, or instructions) was /// exhausted. A stable typed error rather than a bare `Lua(String)` so hosts @@ -562,6 +586,33 @@ impl Error { kind, span: None, message: message.into(), + name: None, + line: None, + column: None, + } + } + + /// Stamps the prompt's frontmatter name onto a parse failure raised by + /// `run` itself: the prompt is already parsed at that point, so the + /// name is known and the location can name it. Any other variant passes + /// through unchanged. + pub(crate) fn with_prompt_name(mut self, name: &str) -> Error { + if let Error::ParseStructured { name: slot, .. } = &mut self { + *slot = Some(name.to_owned()); + } + self + } + + /// Builds an internal-invariant failure, capturing the Rust source + /// position of the call site so [`crate::RunError::location`] can point + /// at the broken invariant. + #[track_caller] + pub(crate) fn internal(message: &'static str) -> Error { + let location = std::panic::Location::caller(); + Error::Internal { + message, + file: location.file(), + line: location.line(), } } @@ -658,20 +709,34 @@ impl From for Error { impl From for Error { fn from(error: crate::parser::ParseError) -> Self { match error.into_inner() { - ParserError::ParseFrontmatter { message, source } => { - Error::ParseFrontmatter { message, source } - } + ParserError::ParseFrontmatter { + message, + source, + line, + column, + } => Error::ParseFrontmatter { + message, + source, + line, + column, + }, ParserError::ParseStructured { kind, span, message, + name, + line, + column, } => Error::ParseStructured { kind, span, message, + name, + line, + column, }, ParserError::Lua(lua) => Error::from(lua), - ParserError::Internal(message) => Error::Internal(message), + ParserError::Internal(message) => Error::internal(message), } } } @@ -703,7 +768,7 @@ impl From for Error { LuaError::ContextExhausted { reason } => Error::ContextExhausted { reason }, LuaError::Interrupted => Error::Interrupted, LuaError::Tool { message, source } => Error::Tool { message, source }, - LuaError::Internal(message) => Error::Internal(message), + LuaError::Internal(message) => Error::internal(message), LuaError::DuplicateAlias { alias } => Error::DuplicateAlias { alias }, LuaError::PickedToolNotLive { alias, id } => Error::PickedToolNotLive { alias, id }, LuaError::ToolIdSelectedTwice { @@ -764,7 +829,10 @@ pub(crate) type Result = std::result::Result; #[cfg(test)] mod tests { + use shared_promptforge_api::observe::NullObserver; + use super::*; + use crate::parser::Prompt; fn assert_source_survives_run_error(error: Error) { assert!( @@ -911,4 +979,75 @@ mod tests { }; assert_source_survives_run_error(bind); } + + #[test] + fn frontmatter_locations_surface_through_the_run_error() { + // Step 6: the parser's surfaced YAML position crosses the substrate + // bridge and lands on `RunError::location` for navigation. A + // frontmatter failure predates the prompt's name, so the path is + // the placeholder a host replaces with its own label for the source. + let source = concat!( + "---\n", + "name: x\n", + "description: d\n", + "capabilities:\n", + " - not a capability id\n", + "---\n", + "\n# T\n\n## S\n\np\n", + ); + let parse = Prompt::parse(source, "test", &NullObserver::default()) + .expect_err("a capability id with spaces must be rejected"); + let run_error = crate::RunError::from(Error::from(parse)); + assert_eq!(run_error.kind(), crate::RunErrorKind::Parse); + let location = run_error + .location() + .expect("a parse failure carries a location"); + assert_eq!(location.line, Some(5)); + assert_eq!(location.column, Some(5)); + assert_eq!(location.span, None); + } + + #[test] + fn structured_locations_carry_the_prompt_name_through_the_run_error() { + // Step 6: a post-frontmatter parse failure carries the prompt's + // frontmatter name as the location's path, plus the offending + // span's line and column. + let source = "---\nname: dup\ndescription: d\n---\n\n# T\n\n## S\n\np\n\n## S\n\nq\n"; + let parse = Prompt::parse(source, "test", &NullObserver::default()) + .expect_err("duplicate sibling sections must be rejected"); + let run_error = crate::RunError::from(Error::from(parse)); + let location = run_error + .location() + .expect("a structured parse failure carries a location"); + assert_eq!(location.path, "dup"); + assert_eq!(location.line, Some(12)); + assert_eq!(location.column, Some(1)); + assert!(location.span.is_some()); + } + + #[test] + fn internal_faults_carry_the_rust_file_and_line() { + // Step 6: an internal invariant failure locates itself in the Rust + // source, captured at the construction site. + let expected_line = line!() + 1; + let run_error = crate::RunError::from(Error::internal("a test invariant")); + let location = run_error + .location() + .expect("an internal fault carries a location"); + assert!( + location.path.ends_with("error.rs"), + "the path is the Rust source file: {}", + location.path + ); + assert_eq!(location.line, Some(expected_line)); + assert_eq!(location.column, None); + } + + #[test] + fn errors_without_a_location_return_none() { + // Cancellation and the other non-positional kinds have no source + // position to navigate to. + let run_error = crate::RunError::from(Error::Interrupted); + assert!(run_error.location().is_none()); + } } diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index 33b4b536..d0539ff3 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -92,7 +92,7 @@ mod tools; // Public API surface. pub use config::{RunContext, RunLimits}; pub use environment::Environment; -pub use error::{RunError, RunErrorKind}; +pub use error::{RunError, RunErrorKind, SourceLocation}; pub(crate) use gateway::ResolutionContext; use context::RunState; @@ -206,10 +206,13 @@ pub async fn run(prompt: &Prompt, args: &str, ctx: RunContext) -> RunResult { return RunResult::Failure(RunError::from(Error::UnsupportedVersion(other))); } None => { - return RunResult::Failure(RunError::from(Error::parse( - ParseErrorKind::Structure, - "not a promptforge prompt: no promptforge version", - ))); + return RunResult::Failure(RunError::from( + Error::parse( + ParseErrorKind::Structure, + "not a promptforge prompt: no promptforge version", + ) + .with_prompt_name(prompt.frontmatter().name()), + )); } } diff --git a/crates/promptforge-api/src/execute/engine.rs b/crates/promptforge-api/src/execute/engine.rs index f94747f4..051a0418 100644 --- a/crates/promptforge-api/src/execute/engine.rs +++ b/crates/promptforge-api/src/execute/engine.rs @@ -102,7 +102,7 @@ pub(super) fn resolve_jump_target( // user-facing Lua error. section_position(siblings, target) .map(JumpTarget::Sibling) - .ok_or(Error::Internal( + .ok_or(Error::internal( "resolved jump target is absent from the jumper's sibling slice", )) } diff --git a/crates/promptforge-api/src/execute/error.rs b/crates/promptforge-api/src/execute/error.rs index a12a7ba3..83ac49f0 100644 --- a/crates/promptforge-api/src/execute/error.rs +++ b/crates/promptforge-api/src/execute/error.rs @@ -1,6 +1,7 @@ //! The public run-error surface: [`RunError`] and its stable [`RunErrorKind`]. use std::fmt; +use std::ops::Range; use crate::Error; @@ -46,6 +47,26 @@ pub enum RunErrorKind { RequirementsUnmet, } +/// Where a failure lives: a prompt source position or a Rust code position. +/// +/// One generic shape - the [`RunErrorKind`] says which world the fault is in, +/// and the path's extension says it again. Kinds are for code, messages for +/// reading, locations for navigation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceLocation { + /// The prompt's frontmatter name when parse got that far, or the Rust + /// source file (from `file!()`) for an internal fault. A frontmatter + /// YAML failure predates the name, so its path is a placeholder the + /// host replaces with its own label for the source. + pub path: String, + /// The 1-based line, when known. + pub line: Option, + /// The 1-based column, when known. + pub column: Option, + /// The byte span of the offending region, as today, when known. + pub span: Option>, +} + /// The error returned by [`run`](super::run), the orchestration boundary of a /// prompt run. /// @@ -89,7 +110,7 @@ impl RunError { | Error::OutOfScopeToolCall { .. } | Error::UnboundToolCall { .. } | Error::Tool { .. } => RunErrorKind::Tool, - Error::Internal(_) | Error::TimestampFormat(_) => RunErrorKind::Internal, + Error::Internal { .. } | Error::TimestampFormat(_) => RunErrorKind::Internal, Error::Store(_) => RunErrorKind::Store, Error::Determinism(_) => RunErrorKind::Determinism, Error::Bind { .. } @@ -132,6 +153,44 @@ impl RunError { _ => false, } } + + /// Returns where the failure lives, when it has a location. + /// + /// Parse-kind failures carry the prompt source position (the frontmatter + /// name as the path when parse got that far, plus the surfaced YAML + /// line/column or the span-derived position); internal faults carry the + /// Rust source file and line of the broken invariant. Other kinds have + /// no source position to navigate to and return `None`. + #[must_use] + pub fn location(&self) -> Option { + match &self.inner { + Error::ParseFrontmatter { line, column, .. } => Some(SourceLocation { + path: "".to_owned(), + line: *line, + column: *column, + span: None, + }), + Error::ParseStructured { + name, + line, + column, + span, + .. + } => Some(SourceLocation { + path: name.clone().unwrap_or_else(|| "".to_owned()), + line: *line, + column: *column, + span: span.map(|(start, end)| start..end), + }), + Error::Internal { file, line, .. } => Some(SourceLocation { + path: (*file).to_owned(), + line: Some(*line), + column: None, + span: None, + }), + _ => None, + } + } } impl fmt::Display for RunError { diff --git a/crates/promptforge-api/src/execute/scheduler.rs b/crates/promptforge-api/src/execute/scheduler.rs index e7e791cb..fc36a97a 100644 --- a/crates/promptforge-api/src/execute/scheduler.rs +++ b/crates/promptforge-api/src/execute/scheduler.rs @@ -296,7 +296,7 @@ fn resolve_arm_target<'a>( }); } } - Err(Error::Internal( + Err(Error::internal( "a resolved arm target is absent from its home slices", )) } @@ -396,7 +396,7 @@ impl Chain<'_> { fn access(&self) -> Result<&Arc> { self.access .as_ref() - .ok_or(Error::Internal("a live chain holds its access capability")) + .ok_or(Error::internal("a live chain holds its access capability")) } /// The chain's current block sequence: the live H1 pass's blocks, or @@ -672,7 +672,7 @@ impl<'a> Scheduler<'a> { // chain, so an empty ready queue with an empty pending table can // only be a driver bug - fail loudly rather than hang. if self.pending.is_empty() { - return Err(Error::Internal( + return Err(Error::internal( "the scheduler stalled with no ready chain and no in-flight request", )); } @@ -693,7 +693,7 @@ impl<'a> Scheduler<'a> { } answer = self.answers.recv() => { let Some((request_id, answer)) = answer else { - return Err(Error::Internal( + return Err(Error::internal( "the answer channel cannot close while the scheduler holds its sender", )); }; @@ -709,7 +709,7 @@ impl<'a> Scheduler<'a> { if self.aborted_requests.remove(&request_id) { continue; } - return Err(Error::Internal( + return Err(Error::internal( "an answer arrived for a request with no pending entry and no recorded abort", )); }; @@ -754,11 +754,11 @@ impl<'a> Scheduler<'a> { arm: Option>, ) -> Result { if self.chains.len() >= self.max_chains { - return Err(Error::Internal("a run's chain count cannot exceed u32")); + return Err(Error::internal("a run's chain count cannot exceed u32")); } let id = ChainId( u32::try_from(self.chains.len()) - .map_err(|_| Error::Internal("a run's chain count cannot exceed u32"))?, + .map_err(|_| Error::internal("a run's chain count cannot exceed u32"))?, ); self.chains.push(Chain { ctx, @@ -830,7 +830,7 @@ impl<'a> Scheduler<'a> { fn start_live_h1(&mut self) -> Result { let id = ChainId( u32::try_from(self.chains.len()) - .map_err(|_| Error::Internal("a run's chain count cannot exceed u32"))?, + .map_err(|_| Error::internal("a run's chain count cannot exceed u32"))?, ); // The pass owns its client slot, seeded from the run's configured // client, exactly as the legacy pass seeds its own. @@ -879,7 +879,7 @@ impl<'a> Scheduler<'a> { fn end_live_h1(&mut self, id: ChainId, root_result: &mut Option>) -> Result<()> { let chain = &mut self.chains[id.index()]; let Some(mut frame) = chain.frame.take() else { - return Err(Error::Internal("the live H1 pass ends with a live frame")); + return Err(Error::internal("the live H1 pass ends with a live frame")); }; let var = frame.read_var()?; drop(frame); @@ -1019,13 +1019,13 @@ impl<'a> Scheduler<'a> { let chain = &mut self.chains[id.index()]; if let Some(answer) = chain.incoming.take() { let Some(thread) = chain.coroutine.take() else { - return Err(Error::Internal( + return Err(Error::internal( "a delivered answer implies a suspended coroutine", )); }; Advance::Resume(thread, answer) } else if chain.coroutine.is_some() { - return Err(Error::Internal( + return Err(Error::internal( "a ready chain's suspended coroutine waits on its answer", )); } else if chain.frame.is_none() { @@ -1039,7 +1039,7 @@ impl<'a> Scheduler<'a> { // `Block` is `#[non_exhaustive]` across the crate seam; a // future variant has no advance rule yet. _ => { - return Err(Error::Internal("an unrecognized block kind cannot advance")); + return Err(Error::internal("an unrecognized block kind cannot advance")); } } } @@ -1055,7 +1055,7 @@ impl<'a> Scheduler<'a> { let text = match &chain.blocks()[chain.block] { Block::Prose { text, .. } => text.clone(), _ => { - return Err(Error::Internal("the advance matched the block kind")); + return Err(Error::internal("the advance matched the block kind")); } }; // The parser emits one prose block per inter-fence gap, @@ -1101,12 +1101,12 @@ impl<'a> Scheduler<'a> { } let slice = chain.slice; let Block::Lua(program) = &slice[chain.index].blocks()[chain.block] else { - return Err(Error::Internal("a suspended coroutine's block is Lua")); + return Err(Error::internal("a suspended coroutine's block is Lua")); }; let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; let result = frame .vm()? .resume_block_coro_answer(program, thread, answer); @@ -1134,7 +1134,7 @@ impl<'a> Scheduler<'a> { let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; if let Err(error) = frame.install_lazy_prose(&chain.ctx, pending.as_deref().unwrap_or("")) { observer.observe(&execution, &name, detail::LUA_CHUNK_FAILED); return Err(error); @@ -1149,12 +1149,12 @@ impl<'a> Scheduler<'a> { } let slice = chain.slice; let Block::Lua(program) = &slice[chain.index].blocks()[chain.block] else { - return Err(Error::Internal("the advance matched the block kind")); + return Err(Error::internal("the advance matched the block kind")); }; let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; let result = frame.vm()?.start_block_coro(program).map_err(Error::from); self.handle_coro_result(id, result, root_result).await } @@ -1211,7 +1211,7 @@ impl<'a> Scheduler<'a> { fn end_section(&mut self, id: ChainId) -> Result<()> { let chain = &mut self.chains[id.index()]; let Some(mut frame) = chain.frame.take() else { - return Err(Error::Internal("a section end implies a live frame")); + return Err(Error::internal("a section end implies a live frame")); }; chain.var = frame.read_var()?; frame.mark_completed(); @@ -1244,7 +1244,7 @@ impl<'a> Scheduler<'a> { let (slice, index) = { let chain = &mut self.chains[id.index()]; let Some(mut frame) = chain.frame.take() else { - return Err(Error::Internal("a jump implies a live frame")); + return Err(Error::internal("a jump implies a live frame")); }; chain.var = frame.read_var()?; frame.mark_completed(); @@ -1343,7 +1343,7 @@ impl<'a> Scheduler<'a> { let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; match frame.vm()?.request_from_yield(&values) { YieldParse::Request(request) => { chain.coroutine = Some(thread); @@ -1398,7 +1398,7 @@ impl<'a> Scheduler<'a> { let mut frame = chain .frame .take() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; frame.read_var()?; drop(frame); *root_result = Some(Ok(value)); @@ -1454,21 +1454,21 @@ impl<'a> Scheduler<'a> { ) -> Result<(Result, Option)> { let chain = &self.chains[id.index()]; let Some(blocks) = chain.h1 else { - return Err(Error::Internal( + return Err(Error::internal( "the scoped step belongs to the live H1 pass", )); }; let Block::Lua(program) = &blocks[chain.block] else { - return Err(Error::Internal("a suspended coroutine's block is Lua")); + return Err(Error::internal("a suspended coroutine's block is Lua")); }; let resolution = self .h1_resolution .as_ref() - .ok_or(Error::Internal("the live H1 pass holds its resolution"))?; + .ok_or(Error::internal("the live H1 pass holds its resolution"))?; let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; let vm = frame.vm()?; let mut outcome = None; let scoped = vm.lua().scope(|scope| { @@ -1480,7 +1480,7 @@ impl<'a> Scheduler<'a> { Ok(()) }); let result = match scoped { - Ok(()) => outcome.ok_or(Error::Internal("the scoped step records its outcome"))?, + Ok(()) => outcome.ok_or(Error::internal("the scoped step records its outcome"))?, Err(error) => Err(Error::lua(error)), }; // The outcome and the captured callback error travel separately: @@ -1552,7 +1552,7 @@ impl<'a> Scheduler<'a> { // stripped coroutines make a hand-rolled yield fail validation // before dispatch - the mirror of the agent driver's guards for // the section-only requests. - Request::Chat { .. } => Err(Error::Internal( + Request::Chat { .. } => Err(Error::internal( "a section VM cannot yield a chat request: the models.chat shim is never installed", )), Request::Mcp { .. } => Err(Error::from(Request::mcp_reserved())), @@ -1593,7 +1593,7 @@ impl<'a> Scheduler<'a> { let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; resolve_model_binding(chain.ctx.models(), &frame.vm()?.model_runtime)?.ok_or_else( || Error::ModelRequired { section: chain.section_name().to_owned(), @@ -1606,7 +1606,7 @@ impl<'a> Scheduler<'a> { let client = chain .client .as_ref() - .ok_or(Error::Internal("the client slot was just resolved"))? + .ok_or(Error::internal("the client slot was just resolved"))? .clone(); let observer = Arc::clone(chain.ctx.observer()); let debug = chain.ctx.debug().cloned(); @@ -1672,7 +1672,7 @@ impl<'a> Scheduler<'a> { // Unreachable: section VMs alone install the `tools.call` shim, // the H1 VM never does, and stripped coroutines make a // hand-rolled yield impossible. - return Err(Error::Internal( + return Err(Error::internal( "the live H1 pass cannot dispatch a tool_call request", )); } @@ -1692,7 +1692,7 @@ impl<'a> Scheduler<'a> { let frame = chain .frame .as_mut() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; let effective = current_tool_bindings(&tool_set, &frame.vm()?.tool_runtime)?; frame.script_call_counts(&ctx, &effective)? }; @@ -1933,7 +1933,7 @@ impl<'a> Scheduler<'a> { // Unreachable: section VMs alone install the models.loop // shim, the H1 VM never does, and stripped coroutines make a // hand-rolled yield impossible. - return Err(Error::Internal( + return Err(Error::internal( "the live H1 pass cannot dispatch a loop request", )); } @@ -1945,7 +1945,7 @@ impl<'a> Scheduler<'a> { let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; resolve_model_binding(chain.ctx.models(), &frame.vm()?.model_runtime)?.ok_or_else( || Error::ModelRequired { section: section.clone(), @@ -1958,7 +1958,7 @@ impl<'a> Scheduler<'a> { let client = chain .client .as_ref() - .ok_or(Error::Internal("the client slot was just resolved"))? + .ok_or(Error::internal("the client slot was just resolved"))? .clone(); let tool_set = chain.ctx.tool_set_snapshot()?; let max_iterations = chain.ctx.max_tool_iterations(); @@ -1967,7 +1967,7 @@ impl<'a> Scheduler<'a> { let frame = chain .frame .as_mut() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; // The scope is read at call time: `tools.add` and // `tools.add_local` calls since the last model operation shape // this call's advertised set. @@ -2030,7 +2030,7 @@ impl<'a> Scheduler<'a> { let frame = chain .frame .as_ref() - .ok_or(Error::Internal("a live chain holds its frame"))?; + .ok_or(Error::internal("a live chain holds its frame"))?; let vm = frame.vm()?; // The append sink: every assistant message and correlated tool // result lands in the author's own message list as its round @@ -2114,7 +2114,7 @@ impl<'a> Scheduler<'a> { // Unreachable: the H1 control stubs raise before anything can // yield. A panic on the empty walk slice would be worse than // the typed invariant error. - return Err(Error::Internal( + return Err(Error::internal( "the live H1 pass cannot dispatch a call request", )); } @@ -2190,7 +2190,7 @@ impl<'a> Scheduler<'a> { // Unreachable: the H1 control stubs raise before anything can // yield. A panic on the empty walk slice would be worse than // the typed invariant error. - return Err(Error::Internal( + return Err(Error::internal( "the live H1 pass cannot dispatch a fanout request", )); } @@ -2222,7 +2222,7 @@ impl<'a> Scheduler<'a> { let access = chain .access .clone() - .ok_or(Error::Internal("a live chain holds its access capability"))?; + .ok_or(Error::internal("a live chain holds its access capability"))?; // `chain`'s arena borrow ends here; the resolution borrows the // prompt tree, so the worker's slice outlives it. let target = self.resolve_chain_target(id, worker_name)?; @@ -2299,7 +2299,7 @@ impl<'a> Scheduler<'a> { loop { let (index, item, template) = { let Some(join) = self.joins.get_mut(&fanout) else { - return Err(Error::Internal("a window refill implies a live join")); + return Err(Error::internal("a window refill implies a live join")); }; if join.next >= join.items.len() || join.active >= join.window { return Ok(()); diff --git a/crates/promptforge-api/src/execute/section_context.rs b/crates/promptforge-api/src/execute/section_context.rs index 747a4dd8..7ce6dd9b 100644 --- a/crates/promptforge-api/src/execute/section_context.rs +++ b/crates/promptforge-api/src/execute/section_context.rs @@ -353,7 +353,7 @@ impl SectionContext { /// failing in practice). pub(crate) fn read_var(&mut self) -> Result { let Some(vm) = self.vm.as_mut() else { - return Err(Error::Internal( + return Err(Error::internal( "the section frame's VM lives until the frame's own drop", )); }; @@ -376,7 +376,7 @@ impl SectionContext { /// Returns [`Error::Internal`] if the VM is gone, which only the frame's /// own drop does - a live frame always holds it. pub(crate) fn vm(&self) -> Result<&SectionVm> { - self.vm.as_ref().ok_or(Error::Internal( + self.vm.as_ref().ok_or(Error::internal( "the section frame's VM lives until the frame's own drop", )) } @@ -441,14 +441,14 @@ impl SectionContext { vm, sys, counts, .. } = self; let Some(vm) = vm.as_ref() else { - return Err(Error::Internal( + return Err(Error::internal( "the section frame's VM lives until the frame's own drop", )); }; install_section_scope(vm, ctx, sys, counts, effective)?; let counts = counts .as_ref() - .ok_or(Error::Internal("the scope install seeds the counts"))?; + .ok_or(Error::internal("the scope install seeds the counts"))?; for binding in effective { counts.ensure(binding.alias())?; } diff --git a/crates/promptforge-api/src/execute/tests/exit_rules.rs b/crates/promptforge-api/src/execute/tests/exit_rules.rs index 0e681c01..3dd4c237 100644 --- a/crates/promptforge-api/src/execute/tests/exit_rules.rs +++ b/crates/promptforge-api/src/execute/tests/exit_rules.rs @@ -99,8 +99,18 @@ async fn missing_version_is_not_a_promptforge_prompt() { .await .expect_err("a prompt with no promptforge version must be declined"); match err { - Error::ParseStructured { kind, message, .. } => { + Error::ParseStructured { + kind, + message, + name, + .. + } => { assert_eq!(kind, ParseErrorKind::Structure); + assert_eq!( + name.as_deref(), + Some("t"), + "the parsed prompt's frontmatter name rides the error" + ); assert!( message.contains("not a promptforge prompt"), "the Parse message must name the missing version, got: {message}" diff --git a/crates/promptforge-api/src/execute/tests/scheduler.rs b/crates/promptforge-api/src/execute/tests/scheduler.rs index c6c9c9c7..00b08ef7 100644 --- a/crates/promptforge-api/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api/src/execute/tests/scheduler.rs @@ -3135,7 +3135,7 @@ async fn an_answer_for_an_unknown_request_id_fails_loudly() { .expect_err("an answer no pending entry explains must fail the run"); assert!( - matches!(error, Error::Internal(message) if message.contains("no pending entry")), + matches!(error, Error::Internal { message, .. } if message.contains("no pending entry")), "the unknown answer is a loud invariant failure: {error}" ); } diff --git a/crates/promptforge-api/src/execute/tool_loop.rs b/crates/promptforge-api/src/execute/tool_loop.rs index f90b81cf..782c6804 100644 --- a/crates/promptforge-api/src/execute/tool_loop.rs +++ b/crates/promptforge-api/src/execute/tool_loop.rs @@ -327,7 +327,7 @@ pub(crate) async fn run_models_loop( // they carry no attached implementation. let Some(local) = local_dispatch else { observer.observe(execution, section, detail::TOOL_CALL_FAILED); - return Err(Error::Internal( + return Err(Error::internal( "a local tool call reached the loop with no local dispatcher", )); }; @@ -427,7 +427,7 @@ pub(crate) async fn run_models_loop( // `CompletionResult` is `#[non_exhaustive]` across the crate // boundary: an outcome this build does not recognize can be neither // dispatched nor promoted to an answer. - _ => return Err(Error::Internal("unrecognized completion outcome")), + _ => return Err(Error::internal("unrecognized completion outcome")), } } @@ -512,12 +512,12 @@ pub(crate) async fn run_prose_inference( ) .await?; let Some(record) = terminal else { - return Err(Error::Internal( + return Err(Error::internal( "a completed loop appended no terminal record", )); }; let MessageContent::Text(text) = record.content else { - return Err(Error::Internal("the terminal record is always plain text")); + return Err(Error::internal("the terminal record is always plain text")); }; Ok(ProseInferenceResult { text: Some(text), diff --git a/crates/promptforge-api/src/lib.rs b/crates/promptforge-api/src/lib.rs index beba1ba1..d1200b29 100644 --- a/crates/promptforge-api/src/lib.rs +++ b/crates/promptforge-api/src/lib.rs @@ -92,6 +92,6 @@ pub(crate) use crate::tools::NearDuplicateDiagnostic; pub use crate::client::{CompletionError, CompletionErrorKind}; pub use crate::execute::{ - Environment, RunContext, RunError, RunErrorKind, RunLimits, RunResult, run, + Environment, RunContext, RunError, RunErrorKind, RunLimits, RunResult, SourceLocation, run, }; pub use crate::parser::{ParseError, ParseErrorKind, Prompt, promptforge_version}; diff --git a/crates/promptforge-parser/src/build.rs b/crates/promptforge-parser/src/build.rs index 53621397..3ff3ce90 100644 --- a/crates/promptforge-parser/src/build.rs +++ b/crates/promptforge-parser/src/build.rs @@ -540,6 +540,9 @@ pub(crate) fn build_sections( message: format!( "duplicate sibling section name `{name}`: first declared at line {first_line}, again at line {heading_abs_line}; sibling section names must be unique" ), + name: None, + line: None, + column: None, }); } sibling_lines.push((name.clone(), heading_abs_line)); diff --git a/crates/promptforge-parser/src/contract/tests.rs b/crates/promptforge-parser/src/contract/tests.rs index 585d4314..a4a4d994 100644 --- a/crates/promptforge-parser/src/contract/tests.rs +++ b/crates/promptforge-parser/src/contract/tests.rs @@ -365,3 +365,37 @@ fn a_zero_min_context_is_rejected() { .expect_err("a zero context minimum must be rejected"); assert_eq!(error.kind(), ParseErrorKind::Frontmatter); } + +#[test] +fn contract_errors_carry_their_frontmatter_line_and_column() { + // Step 6: the retained serde_yaml_ng location surfaces on the parse + // error, so a rejection inside a contract key points at its own line + // and column instead of being a bare message. + let src = concat!( + "---\n", // line 1 + "name: x\n", // line 2 + "description: d\n", // line 3 + "capabilities:\n", // line 4 + " - not a capability id\n", // line 5: the offending scalar + "---\n", + "\n# T\n\n## S\n\np\n", + ); + let error = Prompt::parse(src, "test", &NullObserver::default()) + .expect_err("a capability id with spaces must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert_eq!( + error.line(), + Some(5), + "the offending scalar's line: {error}" + ); + assert_eq!( + error.column(), + Some(5), + "the offending scalar's column: {error}" + ); + assert_eq!( + error.name(), + None, + "a frontmatter failure predates the prompt's name" + ); +} diff --git a/crates/promptforge-parser/src/lib.rs b/crates/promptforge-parser/src/lib.rs index 5aa92d3b..81d45e9d 100644 --- a/crates/promptforge-parser/src/lib.rs +++ b/crates/promptforge-parser/src/lib.rs @@ -60,6 +60,11 @@ pub enum Error { /// The originating YAML parse failure, kept as the cause. #[source] source: BoxedSource, + /// The 1-based file line of the YAML failure, surfaced from the + /// retained cause's location when it carries one. + line: Option, + /// The 1-based file column of the YAML failure, when known. + column: Option, }, /// A structurally-classified parse failure carrying a stable kind and an @@ -74,6 +79,14 @@ pub enum Error { span: Option<(usize, usize)>, /// The human-readable diagnostic. message: String, + /// The prompt's frontmatter name, stamped when the failure postdates + /// the frontmatter (a frontmatter failure predates the name). + name: Option, + /// The 1-based file line of the span's start, computed against the + /// source when a span is known. + line: Option, + /// The 1-based byte column of the span's start, when a span is known. + column: Option, }, /// A Lua region failed to compile at parse time, carried as the @@ -98,10 +111,65 @@ impl Error { kind, span: None, message: message.into(), + name: None, + line: None, + column: None, + } + } + + /// Stamps a structured parse failure with the prompt's frontmatter name + /// and, when the failure carries a source span, the span's 1-based + /// file line and byte column. Every other variant passes through + /// unchanged: a frontmatter failure predates the name, and a Lua + /// compile failure already carries its own position. + fn with_prompt_context(self, name: &str, body: &str, frontmatter_lines: u32) -> Error { + match self { + Error::ParseStructured { + kind, + span, + message, + .. + } => { + let (line, column) = match span { + Some((start, _)) => body_line_column(body, start, frontmatter_lines), + None => (None, None), + }; + Error::ParseStructured { + kind, + span, + message, + name: Some(name.to_owned()), + line, + column, + } + } + other => other, } } } +/// The 1-based file line and byte column of `byte_offset` within `body`, +/// offset past the frontmatter lines. Both are `None` when the offset is +/// out of bounds or the arithmetic overflows - an invariant break that must +/// not replace the original parse failure. +fn body_line_column( + body: &str, + byte_offset: usize, + frontmatter_lines: u32, +) -> (Option, Option) { + let Some(prefix) = body.get(..byte_offset) else { + return (None, None); + }; + let line_in_body = u32::try_from(prefix.matches('\n').count()) + .ok() + .and_then(|newlines| newlines.checked_add(1)); + let line = line_in_body.and_then(|line| frontmatter_lines.checked_add(line)); + let column = u32::try_from(prefix.len() - prefix.rfind('\n').map_or(0, |index| index + 1)) + .ok() + .and_then(|offset| offset.checked_add(1)); + (line, column) +} + /// A stable, matchable classification of a [`ParseError`]. /// /// `#[non_exhaustive]` so new kinds do not break a caller's `match`. @@ -130,19 +198,60 @@ pub enum ParseErrorKind { pub struct ParseError { kind: ParseErrorKind, span: Option<(usize, usize)>, + name: Option, + line: Option, + column: Option, inner: Box, } -/// Classify a substrate error into a stable [`ParseErrorKind`] and optional -/// source span. -/// -/// A structured parse fault carries both directly. -fn classify_parse_error(inner: &Error) -> (ParseErrorKind, Option<(usize, usize)>) { +/// The classified parts of a substrate error: the stable kind plus the +/// location fields the substrate carries (the source span, the prompt's +/// frontmatter name when the failure postdates the frontmatter, and the +/// 1-based line/column - surfaced from the retained YAML failure, or +/// computed from the span). +struct Classification { + kind: ParseErrorKind, + span: Option<(usize, usize)>, + name: Option, + line: Option, + column: Option, +} + +/// Classify a substrate error into its stable kind and location fields. +fn classify_parse_error(inner: &Error) -> Classification { + const NONE: Classification = Classification { + kind: ParseErrorKind::Structure, + span: None, + name: None, + line: None, + column: None, + }; match inner { - Error::ParseStructured { kind, span, .. } => (*kind, *span), - Error::ParseFrontmatter { .. } => (ParseErrorKind::Frontmatter, None), - Error::Lua(promptforge_lua::Error::LuaCompile { .. }) => (ParseErrorKind::Lua, None), - _ => (ParseErrorKind::Structure, None), + Error::ParseStructured { + kind, + span, + name, + line, + column, + .. + } => Classification { + kind: *kind, + span: *span, + name: name.clone(), + line: *line, + column: *column, + }, + Error::ParseFrontmatter { line, column, .. } => Classification { + kind: ParseErrorKind::Frontmatter, + line: *line, + column: *column, + ..NONE + }, + Error::Lua(promptforge_lua::Error::LuaCompile { .. }) => Classification { + kind: ParseErrorKind::Lua, + ..NONE + }, + _ => NONE, } } @@ -162,6 +271,32 @@ impl ParseError { self.span } + /// Returns the prompt's frontmatter name when the failure postdates the + /// frontmatter. + /// + /// A frontmatter YAML failure predates the name (the parser learns the + /// name from the frontmatter itself), so it reports `None` and the + /// host's own label for the source takes its place. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Returns the 1-based file line of the failure, when known. + /// + /// Frontmatter failures surface the retained YAML error's position; + /// structured failures with a source span carry the span's start line. + #[must_use] + pub fn line(&self) -> Option { + self.line + } + + /// Returns the 1-based column of the failure, when known. + #[must_use] + pub fn column(&self) -> Option { + self.column + } + /// Unwraps the internal substrate error. /// /// `#[doc(hidden)]`: cross-crate seam for `promptforge-api`'s own error @@ -187,10 +322,13 @@ impl std::error::Error for ParseError { impl From for ParseError { fn from(inner: Error) -> Self { - let (kind, span) = classify_parse_error(&inner); + let classified = classify_parse_error(&inner); ParseError { - kind, - span, + kind: classified.kind, + span: classified.span, + name: classified.name, + line: classified.line, + column: classified.column, inner: Box::new(inner), } } @@ -422,15 +560,41 @@ impl Prompt { fn parse_inner(input: &str, execution: &str, observer: &dyn Observer) -> Result { let (yaml, body, frontmatter_lines) = split_frontmatter(input)?; let frontmatter: Frontmatter = serde_yaml_ng::from_str(&yaml).map_err(|e| { - // Retain the YAML decode failure as the `#[source]` cause (F3) so the - // public parse error can expose the frontmatter syntax location. + // Retain the YAML decode failure as the `#[source]` cause (F3) and + // surface its location, so the public parse error exposes the + // frontmatter syntax position as stored fields. The location is + // relative to the frontmatter block, which starts on file line 2 + // (line 1 is the opening `---` delimiter). + let (line, column) = e.location().map_or((None, None), |location| { + ( + u32::try_from(location.line()) + .ok() + .and_then(|line| line.checked_add(1)), + u32::try_from(location.column()).ok(), + ) + }); Error::ParseFrontmatter { message: e.to_string(), source: Box::new(e), + line, + column, } })?; + // Everything past the frontmatter postdates the prompt's name, so a + // failure from here on is stamped with it (and its span's position). + let name = frontmatter.name().to_owned(); + Self::parse_body(frontmatter, &body, frontmatter_lines, execution, observer) + .map_err(|error| error.with_prompt_context(&name, &body, frontmatter_lines)) + } - let headings = collect_headings(&body)?; + fn parse_body( + frontmatter: Frontmatter, + body: &str, + frontmatter_lines: u32, + execution: &str, + observer: &dyn Observer, + ) -> Result { + let headings = collect_headings(body)?; let h1_positions: Vec = headings .iter() @@ -456,7 +620,7 @@ impl Prompt { } let title = h1.title.clone(); let h1_content_abs_line = line_add(frontmatter_lines, h1.content_start_line)?; - let shared_fences = exact_shared_openings(&body); + let shared_fences = exact_shared_openings(body); let h1_shared_fences = exact_shared_openings(&h1.content); if shared_fences.len() > 1 { return Err(Error::parse( diff --git a/crates/promptforge-parser/src/tests.rs b/crates/promptforge-parser/src/tests.rs index 7e56498f..1327855b 100644 --- a/crates/promptforge-parser/src/tests.rs +++ b/crates/promptforge-parser/src/tests.rs @@ -38,6 +38,52 @@ fn invalid_frontmatter_preserves_the_yaml_cause_as_source() { ); } +#[test] +fn frontmatter_syntax_errors_carry_a_position_and_no_name() { + // Step 6: a malformed-YAML frontmatter surfaces the retained + // serde_yaml_ng position (1-based, file-absolute); the failure predates + // the prompt's name, so none is reported. + let src = "---\nname: p\ndescription: d\n: : :\n---\n\n# T\n\n## S\n\nhi\n"; + let error = Prompt::parse(src, "test", &NullObserver::default()) + .expect_err("malformed YAML frontmatter must fail to parse"); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert_eq!(error.line(), Some(4), "the malformed line: {error}"); + assert!( + error.column().is_some(), + "a column accompanies the line: {error}" + ); + assert_eq!(error.name(), None); +} + +#[test] +fn structured_errors_carry_the_prompt_name_and_source_position() { + // Step 6: a structured failure postdates the frontmatter, so the parse + // error carries the prompt's frontmatter name plus the offending + // span's 1-based line and column, computed against the source. + let src = concat!( + "---\nname: dup\ndescription: d\n---\n", // lines 1-4 + "\n# T\n\n## S\n\np\n\n## S\n\nq\n", // the second `## S` heads line 12 + ); + let error = Prompt::parse(src, "test", &NullObserver::default()) + .expect_err("duplicate sibling sections must be rejected"); + assert_eq!(error.kind(), ParseErrorKind::Structure); + assert_eq!(error.name(), Some("dup")); + assert_eq!( + error.line(), + Some(12), + "the duplicate heading's line: {error}" + ); + assert_eq!( + error.column(), + Some(1), + "the duplicate heading's column: {error}" + ); + assert!( + error.span().is_some(), + "the byte span is preserved alongside the line/column" + ); +} + #[test] fn mixed_prose_with_one_bullet_is_not_a_list() { // PF-PARSER-005: an incidental bullet line in ordinary prose must not diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 3ba6889c..2b829529 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -916,7 +916,7 @@ Pure refactor, no behavior change: merge `ResolutionContext` and `RunConfig` int -### Step 6: Structured parse error locations +### Step 6: Structured parse error locations [completed] - Component: frontmatter From 1d0c2a5f310b8dfba67fc2d9d5e4f59525ace130 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 21:03:24 -0700 Subject: [PATCH 07/30] Add capability trait and activation types Define the capability activation contract in the shared API crate. A capability identity is a two-segment global name, the activation trait runs once per run at prepare time with the run's services, and the returned contribution is tools-only for now. Activation failure is a model-safe error: a stable kind for code, a message written to be read by a model, and the underlying cause hidden behind the error source. The crate gains a dependency on the std-only virtual filesystem crate so an activation can be handed the run's filesystem. - `CapabilityId` wraps `GlobalName` in a distinct nominal type and `parse` accepts exactly two segments, so a three-segment tool id is rejected as a capability identity. It serializes as its one string form and re-validates on deserialize, so an invalid string is never silently accepted. - `Capability` is an object-safe `Send + Sync` trait of `id`, `description`, and `create`, which receives the run services and returns the contribution. A const assertion and a test pin `Arc` as shareable across threads. - `RunServices` is non-exhaustive and carries only `vfs` and `cancel`, so the deferred broker, observer, and client fields can arrive without breaking existing implementations. - `Contribution` is `Default` and tools-only: `tools` holds `Arc` entries whose ids must live under the contributing capability's own id. - `CapabilityError` pairs a stable `CapabilityErrorKind` with a model-readable display message and keeps the cause behind `Error::source`, mirroring the tool error contract. - `CapabilityId::parse` rejects wrong segment counts, empty segments, and out-of-charset characters including `@` with distinct `CapabilityIdErrorKind` values, and the tests cover each case plus the serde round-trip. - Nothing in this change calls `Capability::create` outside tests, and the documented containment check on contributed tool ids has no assembly code here to enforce it. Design: new newtype @ crates/shared-promptforge-api/src/capabilities.rs::CapabilityId boundary: wire Design: new encapsulated-invariant @ crates/shared-promptforge-api/src/capabilities.rs::CapabilityId Design: new speculative-abstraction @ crates/shared-promptforge-api/src/capabilities.rs::Capability boundary: pub Design: new surface-growth @ crates/shared-promptforge-api/src/capabilities.rs boundary: pub Deferred: mounts, prompt fragments, and Lua surface on Contribution until the capabilities that need them land Deferred: the input broker, observer, and model client fields on RunServices until a bridge capability needs them Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- Cargo.lock | 1 + crates/shared-promptforge-api/Cargo.toml | 1 + .../src/capabilities.rs | 452 ++++++++++++++++++ .../src/capabilities/tests.rs | 158 ++++++ crates/shared-promptforge-api/src/lib.rs | 11 +- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 6 files changed, 621 insertions(+), 4 deletions(-) create mode 100644 crates/shared-promptforge-api/src/capabilities.rs create mode 100644 crates/shared-promptforge-api/src/capabilities/tests.rs diff --git a/Cargo.lock b/Cargo.lock index a8824638..88305e33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6059,6 +6059,7 @@ dependencies = [ "rand 0.9.5", "serde", "serde_json", + "shared-vfs", "thiserror 2.0.19", "tokio", "tokio-util", diff --git a/crates/shared-promptforge-api/Cargo.toml b/crates/shared-promptforge-api/Cargo.toml index d49ad326..52971bcb 100644 --- a/crates/shared-promptforge-api/Cargo.toml +++ b/crates/shared-promptforge-api/Cargo.toml @@ -17,6 +17,7 @@ async-trait.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true +shared-vfs.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["sync"] } tokio-util.workspace = true diff --git a/crates/shared-promptforge-api/src/capabilities.rs b/crates/shared-promptforge-api/src/capabilities.rs new file mode 100644 index 00000000..74ad3981 --- /dev/null +++ b/crates/shared-promptforge-api/src/capabilities.rs @@ -0,0 +1,452 @@ +//! The capability activation contract. +//! +//! A capability is the activation unit: code that runs at run setup and +//! makes services available to the run. Capabilities are delivered in packs +//! (crates now, DLLs via adapters later) and identified by a 2-segment +//! [`GlobalName`](crate::names::GlobalName) - kind is encoded by arity, so a +//! capability id is `namespace/pack` and every tool it contributes lives +//! under `namespace/pack/name`. At prepare time the executor activates each +//! declared capability by calling [`Capability::create`] with the run's +//! [`RunServices`]; the returned [`Contribution`] is v1 tools-only and grows +//! without redesign. An activation failure is a [`CapabilityError`]: a +//! stable kind for code plus a message written to be read by a model, +//! mirroring [`ToolError`](crate::tools::ToolError). + +use std::sync::Arc; + +use shared_vfs::VfsRef; + +use crate::cancel::CancelHandle; +use crate::names::{GlobalName, GlobalNameErrorKind}; +use crate::tools::Tool; + +#[cfg(test)] +mod tests; + +/// The stable identity of an installed capability. +/// +/// Identity is a 2-segment [`GlobalName`] (`namespace/pack`): the global +/// naming grammar encodes kind by arity, and a capability's id is the +/// prefix of every tool id it contributes (`promptforge/web` contributes +/// `promptforge/web/fetch`, no exceptions). v1 is unversioned: a name +/// resolves to the only installed capability and a `@` is a parse error. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub struct CapabilityId(GlobalName); + +impl CapabilityId { + /// Parses a capability identity, requiring exactly 2 segments + /// (`namespace/pack`). + /// + /// # Errors + /// Returns [`CapabilityIdError`] when the segment count is not exactly 2 + /// ([`CapabilityIdErrorKind::SegmentCount`]), a segment is empty + /// ([`CapabilityIdErrorKind::Empty`]), or a segment contains a character + /// outside the global-name charset ([`CapabilityIdErrorKind::Control`]). + /// + /// # Examples + /// + /// ``` + /// use shared_promptforge_api::capabilities::CapabilityId; + /// + /// let id = CapabilityId::parse("promptforge/web")?; + /// assert_eq!(id.namespace(), "promptforge"); + /// assert_eq!(id.pack(), "web"); + /// # Ok::<(), shared_promptforge_api::capabilities::CapabilityIdError>(()) + /// ``` + pub fn parse(id: &str) -> Result { + let name = GlobalName::parse(id) + .map_err(|e| CapabilityIdError::from_global_name_kind(e.kind()))?; + if name.segments().len() != 2 { + return Err(CapabilityIdError { + kind: CapabilityIdErrorKind::SegmentCount, + reason: "a capability id must have exactly 2 segments (namespace/pack)", + }); + } + Ok(CapabilityId(name)) + } + + /// Builds an identity from a string already known to be valid. + /// + /// For internal callers whose inputs are static capability ids, so the + /// validation in [`CapabilityId::parse`] is redundant. Hidden from the + /// public API: downstream callers use [`CapabilityId::parse`]. + #[doc(hidden)] + #[must_use] + pub fn from_validated(id: &str) -> CapabilityId { + let name = GlobalName::from_validated(id); + debug_assert!( + name.segments().len() == 2, + "a static capability id must have exactly 2 segments (namespace/pack): {id}" + ); + CapabilityId(name) + } + + /// Returns the namespace segment (reverse-DNS or `promptforge`). + /// + /// # Examples + /// + /// ``` + /// use shared_promptforge_api::capabilities::CapabilityId; + /// + /// let id = CapabilityId::parse("org.rustalliance/core")?; + /// assert_eq!(id.namespace(), "org.rustalliance"); + /// # Ok::<(), shared_promptforge_api::capabilities::CapabilityIdError>(()) + /// ``` + #[must_use] + pub fn namespace(&self) -> &str { + self.0.namespace() + } + + /// Returns the pack segment. + /// + /// # Examples + /// + /// ``` + /// use shared_promptforge_api::capabilities::CapabilityId; + /// + /// let id = CapabilityId::parse("promptforge/web")?; + /// assert_eq!(id.pack(), "web"); + /// # Ok::<(), shared_promptforge_api::capabilities::CapabilityIdError>(()) + /// ``` + #[must_use] + pub fn pack(&self) -> &str { + self.0.pack() + } +} + +impl std::fmt::Display for CapabilityId { + /// The canonical `namespace/pack` string form. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl serde::Serialize for CapabilityId { + /// Serializes the identity as its one `namespace/pack` string. + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for CapabilityId { + /// Deserializes the identity from its string form, validating it as a + /// 2-segment global name: an invalid string is a data error, never a + /// silently accepted identity. + fn deserialize>(deserializer: D) -> Result { + let text = ::deserialize(deserializer)?; + CapabilityId::parse(&text).map_err(serde::de::Error::custom) + } +} + +/// A stable, matchable classification of a [`CapabilityIdError`]. +/// +/// Every public error exposes a `kind()` classifier so callers can branch on +/// the failure without matching a private representation (DESIGN-5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CapabilityIdErrorKind { + /// The id did not have exactly 2 segments (`namespace/pack`). + SegmentCount, + /// A segment was empty. + Empty, + /// A segment contained a character outside the allowed set. + Control, +} + +/// The reason a [`CapabilityId`] could not be parsed. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("invalid capability id: {reason}")] +#[non_exhaustive] +pub struct CapabilityIdError { + /// A stable classification of why the id was rejected. + kind: CapabilityIdErrorKind, + /// A human-readable reason. + reason: &'static str, +} + +impl CapabilityIdError { + /// Returns the stable classification of this error (DESIGN-5). + #[must_use] + pub fn kind(&self) -> CapabilityIdErrorKind { + self.kind + } + + /// Maps a global-name rejection onto the capability-id error vocabulary. + fn from_global_name_kind(global_kind: GlobalNameErrorKind) -> CapabilityIdError { + let (kind, reason) = match global_kind { + GlobalNameErrorKind::SegmentCount => ( + CapabilityIdErrorKind::SegmentCount, + "a capability id must have exactly 2 segments (namespace/pack)", + ), + GlobalNameErrorKind::Empty => { + (CapabilityIdErrorKind::Empty, "segments must not be empty") + } + GlobalNameErrorKind::Control => ( + CapabilityIdErrorKind::Control, + "segments may contain only lowercase ASCII letters, digits, '-', '_', '.'", + ), + }; + CapabilityIdError { kind, reason } + } +} + +/// The activation unit: code that runs at run setup and makes services +/// available to the run. +/// +/// A capability is delivered in a pack (a crate now, a DLL via an adapter +/// later) and declared in a prompt's frontmatter by its +/// [`id`](Capability::id). At prepare time the executor calls +/// [`create`](Capability::create) once per declared capability, in +/// declaration order, and assembles the returned [`Contribution`] into the +/// run's tool catalog. +/// +/// # Implementing +/// +/// ``` +/// use shared_promptforge_api::capabilities::{ +/// Capability, CapabilityError, CapabilityId, Contribution, RunServices, +/// }; +/// +/// struct Web { +/// id: CapabilityId, +/// } +/// +/// impl Capability for Web { +/// fn id(&self) -> &CapabilityId { +/// &self.id +/// } +/// fn description(&self) -> &str { +/// "Web fetch and search tools." +/// } +/// fn create(&self, services: &RunServices) -> Result { +/// let _ = services; +/// Ok(Contribution::default()) +/// } +/// } +/// +/// let web = Web { +/// id: CapabilityId::parse("promptforge/web")?, +/// }; +/// assert_eq!(web.id().pack(), "web"); +/// # Ok::<(), shared_promptforge_api::capabilities::CapabilityIdError>(()) +/// ``` +/// +/// # Invariants +/// +/// - [`id`](Capability::id) returns the same value on every call; it is the +/// registry key and must be unique within a registry. +/// - Every contributed tool's id lives under the capability's own id: +/// `namespace/pack/name` for a `namespace/pack` capability. Containment is +/// total and is checked when the run's catalog is assembled. +/// - [`create`](Capability::create) must not panic and should return +/// promptly when the run is cancelled. +pub trait Capability: Send + Sync { + /// Returns the capability's stable identity (`namespace/pack`). + fn id(&self) -> &CapabilityId; + + /// A one-sentence description, surfaced to hosts and to the + /// registration-time near-duplicate lint. + fn description(&self) -> &str; + + /// Activates the capability for one run. + /// + /// Called once per run at prepare time with the run's services. A + /// failure returns a narrow, model-safe [`CapabilityError`] and the + /// capability contributes nothing to the run. + /// + /// # Errors + /// Returns a [`CapabilityError`] if the capability cannot activate (a + /// missing host service, a failed backend handshake, cancellation). + fn create(&self, services: &RunServices) -> Result; +} + +/// What a capability is given at activation. +/// +/// Non-exhaustive so new fields (the input broker, the observer, the model +/// client) can be added when a bridge capability needs them without +/// breaking existing capability implementations. Host-supplied +/// per-capability config arrives here, never via the prompt. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct RunServices { + /// The run's filesystem. + pub vfs: VfsRef, + /// The run's cancellation handle. + pub cancel: CancelHandle, +} + +impl RunServices { + /// Builds the services handed to [`Capability::create`] for one run. + /// + /// # Examples + /// + /// ``` + /// use shared_promptforge_api::cancel::CancelHandle; + /// use shared_promptforge_api::capabilities::RunServices; + /// + /// let services = RunServices::new(shared_vfs::VfsRef::builder().build(), CancelHandle::new()); + /// assert!(!services.cancel.is_cancelled()); + /// ``` + #[must_use] + pub fn new(vfs: VfsRef, cancel: CancelHandle) -> RunServices { + RunServices { vfs, cancel } + } +} + +/// What a capability contributes to a run. +/// +/// v1 is tools-only: mounts, prompt fragments, and Lua surface are deferred +/// until the capabilities that need them land. The struct is +/// [`Default`] and grows without redesign. +/// +/// # Examples +/// +/// ``` +/// use shared_promptforge_api::capabilities::Contribution; +/// +/// let contribution = Contribution::default(); +/// assert!(contribution.tools.is_empty()); +/// ``` +#[derive(Default)] +pub struct Contribution { + /// The contributed tools, each identified under the capability's own + /// full id (`namespace/pack/name` for a `namespace/pack` capability). + pub tools: Vec>, +} + +impl std::fmt::Debug for Contribution { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Contribution") + .field( + "tools", + &self.tools.iter().map(|tool| tool.id()).collect::>(), + ) + .finish() + } +} + +/// A stable, matchable classification of a [`CapabilityError`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CapabilityErrorKind { + /// The capability's activation ([`Capability::create`]) failed. + Activation, + /// The run was cancelled before or during activation. + Cancelled, + /// Any other capability failure. + Other, +} + +/// A narrow, model-safe error from [`Capability::create`]. +/// +/// The `Display` message is caller-facing and safe to hand to a model; any +/// underlying cause is hidden behind [`std::error::Error::source`]. Match on +/// [`CapabilityError::kind`] rather than a private representation. This +/// mirrors [`ToolError`](crate::tools::ToolError): a stable kind for code, a +/// message written to be read by a model. +#[derive(Debug)] +#[non_exhaustive] +pub struct CapabilityError { + kind: CapabilityErrorKind, + message: String, + source: Option>, +} + +impl CapabilityError { + /// Builds a model-safe error carrying only a message (kind `Other`). + /// + /// # Examples + /// ``` + /// use shared_promptforge_api::capabilities::{CapabilityError, CapabilityErrorKind}; + /// + /// let err = CapabilityError::message("the fs capability needs a writable store"); + /// assert_eq!(err.kind(), CapabilityErrorKind::Other); + /// ``` + #[must_use] + pub fn message(text: impl Into) -> CapabilityError { + CapabilityError { + kind: CapabilityErrorKind::Other, + message: text.into(), + source: None, + } + } + + /// Builds a model-safe activation error with `src` as a hidden + /// `#[source]`. + /// + /// The initial kind is [`CapabilityErrorKind::Activation`]; use + /// [`CapabilityError::with_kind`] when the source represents another + /// class. + /// + /// # Examples + /// ``` + /// use shared_promptforge_api::capabilities::{CapabilityError, CapabilityErrorKind}; + /// + /// let io = std::io::Error::other("boom"); + /// let err = CapabilityError::with_source("activation failed", io); + /// assert_eq!(err.kind(), CapabilityErrorKind::Activation); + /// assert!(std::error::Error::source(&err).is_some()); + /// ``` + #[must_use] + pub fn with_source( + text: impl Into, + src: impl std::error::Error + Send + Sync + 'static, + ) -> CapabilityError { + CapabilityError { + kind: CapabilityErrorKind::Activation, + message: text.into(), + source: Some(Box::new(src)), + } + } + + /// Sets the classification, returning the updated error. + /// + /// # Examples + /// ``` + /// use shared_promptforge_api::capabilities::{CapabilityError, CapabilityErrorKind}; + /// + /// let err = CapabilityError::message("stopped").with_kind(CapabilityErrorKind::Cancelled); + /// assert!(err.is_cancelled()); + /// ``` + #[must_use] + pub fn with_kind(mut self, kind: CapabilityErrorKind) -> CapabilityError { + self.kind = kind; + self + } + + /// Returns the stable classification of this error. + #[must_use] + pub fn kind(&self) -> CapabilityErrorKind { + self.kind + } + + /// Returns whether the failure was a cancellation. + /// + /// # Examples + /// ``` + /// use shared_promptforge_api::capabilities::{CapabilityError, CapabilityErrorKind}; + /// + /// let err = CapabilityError::message("stopped").with_kind(CapabilityErrorKind::Cancelled); + /// assert!(err.is_cancelled()); + /// ``` + #[must_use] + pub fn is_cancelled(&self) -> bool { + matches!(self.kind, CapabilityErrorKind::Cancelled) + } +} + +impl std::fmt::Display for CapabilityError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for CapabilityError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|boxed| boxed.as_ref() as &(dyn std::error::Error + 'static)) + } +} diff --git a/crates/shared-promptforge-api/src/capabilities/tests.rs b/crates/shared-promptforge-api/src/capabilities/tests.rs new file mode 100644 index 00000000..0da05167 --- /dev/null +++ b/crates/shared-promptforge-api/src/capabilities/tests.rs @@ -0,0 +1,158 @@ +//! Tests for the capability activation contract. + +use std::sync::Arc; + +use super::{ + Capability, CapabilityError, CapabilityErrorKind, CapabilityId, CapabilityIdErrorKind, + Contribution, RunServices, +}; +use crate::cancel::CancelHandle; + +/// A minimal in-process capability: a static id, no contributed tools, and +/// a `create` that refuses a cancelled run so tests can observe the +/// services it was handed. +struct StubCapability { + id: CapabilityId, + description: String, +} + +impl StubCapability { + fn web() -> StubCapability { + StubCapability { + id: CapabilityId::parse("promptforge/web").expect("a static valid id"), + description: "A stub capability that contributes nothing.".to_owned(), + } + } +} + +impl Capability for StubCapability { + fn id(&self) -> &CapabilityId { + &self.id + } + + fn description(&self) -> &str { + &self.description + } + + fn create(&self, services: &RunServices) -> Result { + if services.cancel.is_cancelled() { + return Err( + CapabilityError::message("activation cancelled before create") + .with_kind(CapabilityErrorKind::Cancelled), + ); + } + Ok(Contribution::default()) + } +} + +/// Compile-time proof that a capability can be shared across tasks and +/// threads behind a trait object: the registry stores `Arc`. +const fn _assert_capability_trait_object_is_shareable() { + const fn assert_send_sync_static() {} + assert_send_sync_static::>(); +} + +#[test] +fn capability_id_requires_exactly_two_segments() { + let id = CapabilityId::parse("promptforge/web").expect("two segments parse"); + assert_eq!(id.to_string(), "promptforge/web"); + + let three = CapabilityId::parse("promptforge/web/fetch") + .expect_err("three segments are a tool id, not a capability id"); + assert_eq!(three.kind(), CapabilityIdErrorKind::SegmentCount); + + let one = CapabilityId::parse("promptforge").expect_err("one segment names nothing"); + assert_eq!(one.kind(), CapabilityIdErrorKind::SegmentCount); +} + +#[test] +fn capability_id_rejects_empty_and_control_segments() { + let empty = CapabilityId::parse("promptforge/").expect_err("an empty segment is rejected"); + assert_eq!(empty.kind(), CapabilityIdErrorKind::Empty); + + let upper = CapabilityId::parse("Promptforge/web") + .expect_err("comparison is case-sensitive: uppercase is outside the charset"); + assert_eq!(upper.kind(), CapabilityIdErrorKind::Control); + + let versioned = CapabilityId::parse("promptforge/web@2") + .expect_err("v1 is unversioned: '@' is a parse error"); + assert_eq!(versioned.kind(), CapabilityIdErrorKind::Control); +} + +#[test] +fn capability_id_exposes_namespace_and_pack() { + let id = CapabilityId::parse("org.rustalliance/core").expect("a reverse-DNS namespace parses"); + assert_eq!(id.namespace(), "org.rustalliance"); + assert_eq!(id.pack(), "core"); +} + +#[test] +fn capability_id_serializes_as_its_string_form() { + let id = CapabilityId::parse("promptforge/web").expect("a static valid id"); + let json = serde_json::to_string(&id).expect("serializes"); + assert_eq!(json, "\"promptforge/web\""); + let back: CapabilityId = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(back, id); + assert!( + serde_json::from_str::("\"promptforge/web/fetch\"").is_err(), + "a 3-segment string is a tool id, never a capability id" + ); +} + +#[test] +fn a_capability_is_object_safe_and_exposes_its_identity() { + let capability: Arc = Arc::new(StubCapability::web()); + assert_eq!(capability.id().to_string(), "promptforge/web"); + assert!(!capability.description().is_empty()); +} + +#[test] +fn a_default_contribution_has_no_tools() { + let contribution = Contribution::default(); + assert!(contribution.tools.is_empty()); +} + +#[test] +fn create_receives_the_run_services() { + let capability = StubCapability::web(); + let services = RunServices::new(shared_vfs::VfsRef::builder().build(), CancelHandle::new()); + let contribution = capability + .create(&services) + .expect("activation succeeds on a live run"); + assert!(contribution.tools.is_empty()); + + let cancel = CancelHandle::new(); + cancel.cancel(); + let services = RunServices::new(shared_vfs::VfsRef::builder().build(), cancel); + let error = capability + .create(&services) + .expect_err("a cancelled run fails activation"); + assert!(error.is_cancelled()); +} + +#[test] +fn capability_error_display_is_the_model_readable_message() { + let error = CapabilityError::message("the fs capability needs a writable store"); + assert_eq!( + error.to_string(), + "the fs capability needs a writable store" + ); + assert_eq!(error.kind(), CapabilityErrorKind::Other); + assert!(std::error::Error::source(&error).is_none()); +} + +#[test] +fn capability_error_classifies_and_hides_its_cause() { + let io = std::io::Error::other("disk full"); + let error = CapabilityError::with_source("activation failed", io); + assert_eq!(error.kind(), CapabilityErrorKind::Activation); + assert_eq!(error.to_string(), "activation failed"); + assert!( + std::error::Error::source(&error).is_some(), + "the cause rides behind Error::source, out of the model-readable message" + ); + + let cancelled = CapabilityError::message("stopped").with_kind(CapabilityErrorKind::Cancelled); + assert!(cancelled.is_cancelled()); + assert!(!CapabilityError::message("x").is_cancelled()); +} diff --git a/crates/shared-promptforge-api/src/lib.rs b/crates/shared-promptforge-api/src/lib.rs index f5d83d04..9c1eab67 100644 --- a/crates/shared-promptforge-api/src/lib.rs +++ b/crates/shared-promptforge-api/src/lib.rs @@ -11,11 +11,16 @@ //! 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. +//! tool error, and [`capabilities`] is the capability activation contract: +//! the [`Capability`](capabilities::Capability) trait, the +//! [`RunServices`](capabilities::RunServices) a capability is given at +//! activation, and the [`Contribution`](capabilities::Contribution) it +//! returns. This +//! crate's only workspace dependency is the std-only `shared-vfs`, so every +//! promptforge crate may depend on it. pub mod cancel; +pub mod capabilities; pub mod events; pub mod models; pub mod names; diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 2b829529..c3320780 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -926,7 +926,7 @@ Surface the retained `serde_yaml_ng` location into parse errors and add `SourceL -### Step 7: Capability trait and activation types +### Step 7: Capability trait and activation types [completed] - Component: capabilities From f859ecad0aaf252c8584351068e87b52f5dd88b7 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 21:15:54 -0700 Subject: [PATCH 08/30] Add explicit CapabilityRegistry with registration lint A host now builds one explicit registry of its installed capabilities; linking a capability crate alone registers nothing. Registration is unversioned, one capability per id, so a duplicate id is rejected and the first registration survives. Each registration also runs an advisory near-duplicate lint over capability descriptions through the picker's deterministic fallback embeddings, logging a warning that names both capabilities without ever failing the registration. - `CapabilityRegistry` stores installed capabilities in a BTreeMap keyed by CapabilityId alongside a lint index rebuilt at each registration; its Debug impl reports only the registered ids, never the capabilities themselves. - `crates/promptforge-api/src/lib.rs` gains the public capabilities module and re-exports the registry with its error types, widening the crate's public surface. - `register` rejects a duplicate id with RegistryErrorKind::DuplicateId and keeps the first registration. - `lint_new_registration` warns on each near-duplicate description pair exactly once, at the registration that created it; lint machinery failures degrade to a warning and never fail registration. - `crates/promptforge-api/src/capabilities.rs` carries no tool prefix-containment check: tools exist only after create, so containment runs when a run's catalog is assembled, not at registration. Design: new registry @ crates/promptforge-api/src/capabilities.rs::CapabilityRegistry boundary: pub Design: new surface-growth @ crates/promptforge-api/src/lib.rs boundary: pub Design: new swallowed-exception @ crates/promptforge-api/src/capabilities.rs::lint_new_registration Design: new pure-function @ crates/promptforge-api/src/capabilities.rs::lint_key deps: CapabilityId Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- Cargo.lock | 2 + crates/promptforge-api/Cargo.toml | 2 + crates/promptforge-api/src/capabilities.rs | 233 ++++++++++++++++++ .../promptforge-api/src/capabilities/tests.rs | 160 ++++++++++++ crates/promptforge-api/src/lib.rs | 2 + ...2026-09-13-1-capabilities-global-naming.md | 2 +- 6 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 crates/promptforge-api/src/capabilities.rs create mode 100644 crates/promptforge-api/src/capabilities/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 88305e33..765f49a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4835,6 +4835,8 @@ dependencies = [ "thiserror 2.0.19", "time", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/promptforge-api/Cargo.toml b/crates/promptforge-api/Cargo.toml index 19b2a710..79e4522a 100644 --- a/crates/promptforge-api/Cargo.toml +++ b/crates/promptforge-api/Cargo.toml @@ -27,6 +27,7 @@ serde.workspace = true serde_json.workspace = true shared-vfs.workspace = true thiserror.workspace = true +tracing.workspace = true mlua.workspace = true time.workspace = true tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } @@ -37,6 +38,7 @@ criterion.workspace = true promptforge-parser = { workspace = true, features = ["test-support"] } promptforge-tool-picker = { workspace = true, features = ["test-fixtures"] } tokio = { workspace = true, features = ["test-util"] } +tracing-subscriber.workspace = true [[bench]] name = "models_loop" diff --git a/crates/promptforge-api/src/capabilities.rs b/crates/promptforge-api/src/capabilities.rs new file mode 100644 index 00000000..4a6e3c10 --- /dev/null +++ b/crates/promptforge-api/src/capabilities.rs @@ -0,0 +1,233 @@ +//! The explicit host-built capability registry: [`CapabilityRegistry`]. +//! +//! Linking a capability crate alone registers nothing: a host constructs one +//! registry, registers each installed capability by hand, and hands the +//! registry to the [`Environment`](crate::execute::Environment). v1 is +//! unversioned - one capability per id - so a duplicate registration is +//! rejected rather than shadowing the installed capability. +//! +//! Registration runs an advisory near-duplicate lint over capability +//! descriptions through the picker (the engine behind fuzzy tool slots): +//! two installed capabilities whose descriptions are near-verbatim copies +//! almost certainly overlap in what they offer, so the lint logs a warning +//! naming both. The lint never fails a registration. The tool +//! prefix-containment check is not here: tools exist only after +//! [`Capability::create`], so containment is checked when a run's catalog +//! is assembled, not at registration. +//! +//! # Examples +//! +//! ``` +//! use std::sync::Arc; +//! +//! use promptforge_api::capabilities::{CapabilityRegistry, RegistryErrorKind}; +//! use shared_promptforge_api::capabilities::{ +//! Capability, CapabilityError, CapabilityId, Contribution, RunServices, +//! }; +//! +//! struct Web { +//! id: CapabilityId, +//! } +//! +//! impl Capability for Web { +//! fn id(&self) -> &CapabilityId { +//! &self.id +//! } +//! fn description(&self) -> &str { +//! "Web fetch and search tools." +//! } +//! fn create(&self, services: &RunServices) -> Result { +//! let _ = services; +//! Ok(Contribution::default()) +//! } +//! } +//! +//! let mut registry = CapabilityRegistry::new(); +//! let id = CapabilityId::parse("promptforge/web")?; +//! registry.register(Arc::new(Web { id: id.clone() }))?; +//! assert!(registry.get(&id).is_some()); +//! +//! let duplicate = registry.register(Arc::new(Web { id: id.clone() })); +//! assert_eq!( +//! duplicate.map(|_| ()).unwrap_err().kind(), +//! RegistryErrorKind::DuplicateId +//! ); +//! # Ok::<(), Box>(()) +//! ``` + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::Arc; + +use promptforge_tool_picker::{Catalog, Config, ToolDescriptor, ToolId, ToolPicker}; +use shared_promptforge_api::capabilities::{Capability, CapabilityId}; + +#[cfg(test)] +mod tests; + +/// The synthetic third segment keying a capability in the lint catalog. +/// +/// The picker's catalog speaks three-segment tool ids, so each capability +/// is keyed `//capability`; stripping the segment maps a +/// lint pair back to the capability it names. +const LINT_KEY_SEGMENT: &str = "capability"; + +/// An explicit host-built registry of installed capabilities. +/// +/// See the [module documentation](self) for the registration rules and the +/// near-duplicate description lint. +pub struct CapabilityRegistry { + /// The installed capabilities, keyed by their stable ids. + capabilities: BTreeMap>, + /// The lint index over the registered descriptions, rebuilt at each + /// registration. `ToolPicker::empty` skips the embedding-model load, so + /// the lint runs on the picker's deterministic fallback embeddings. + lint: ToolPicker, +} + +impl CapabilityRegistry { + /// Builds an empty registry. + #[must_use] + pub fn new() -> CapabilityRegistry { + CapabilityRegistry { + capabilities: BTreeMap::new(), + lint: ToolPicker::empty(Config::default()), + } + } + + /// Registers an installed capability. + /// + /// The near-duplicate description lint runs after the insert; a lint + /// hit logs a warning and does not fail the registration. + /// + /// # Errors + /// Returns [`RegistryError`] with [`RegistryErrorKind::DuplicateId`] + /// when a capability with the same id is already registered; the + /// registry keeps the first registration. + pub fn register(&mut self, capability: Arc) -> Result<(), RegistryError> { + let id = capability.id().clone(); + if self.capabilities.contains_key(&id) { + return Err(RegistryError { + kind: RegistryErrorKind::DuplicateId, + id, + }); + } + self.capabilities.insert(id.clone(), capability); + self.lint_new_registration(&id); + Ok(()) + } + + /// Returns the capability registered under `id`, when present. + #[must_use] + pub fn get(&self, id: &CapabilityId) -> Option<&Arc> { + self.capabilities.get(id) + } + + /// Rebuilds the lint index over every registered description and warns + /// on each near-duplicate pair involving the capability just + /// registered, so a pair is reported exactly once, at the registration + /// that created it. Lint machinery failures degrade to a warning: + /// registration itself never fails on the lint. + fn lint_new_registration(&mut self, new_id: &CapabilityId) { + let descriptors = self + .capabilities + .values() + .map(|capability| { + ToolDescriptor::new( + lint_key(capability.id()), + capability.description(), + serde_json::json!({}), + ) + }) + .collect::>(); + let picker = match self.lint.rebuild(Catalog::new(descriptors)) { + Ok(picker) => picker, + Err(error) => { + tracing::warn!(%error, "capability near-duplicate lint skipped: index rebuild failed"); + return; + } + }; + let keys = picker + .iter() + .map(|descriptor| descriptor.id().clone()) + .collect::>(); + let new_key = lint_key(new_id); + match picker.near_duplicates(&keys) { + Ok(pairs) => { + for pair in &pairs { + if pair.first().id() == &new_key || pair.second().id() == &new_key { + tracing::warn!( + first = %pair.first().id().capability(), + second = %pair.second().id().capability(), + similarity = pair.similarity(), + "registered capability descriptions are near-duplicates" + ); + } + } + } + Err(error) => { + tracing::warn!(%error, "capability near-duplicate lint skipped: analysis failed"); + } + } + self.lint = picker; + } +} + +impl Default for CapabilityRegistry { + fn default() -> CapabilityRegistry { + CapabilityRegistry::new() + } +} + +impl fmt::Debug for CapabilityRegistry { + /// Reports the registered ids, never the capabilities themselves. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapabilityRegistry") + .field( + "capabilities", + &self.capabilities.keys().collect::>(), + ) + .finish_non_exhaustive() + } +} + +/// Maps a capability id onto its lint-catalog key. Valid by construction: +/// a capability id's two segments and the key segment are all valid +/// global-name segments. +fn lint_key(id: &CapabilityId) -> ToolId { + ToolId::from_validated(&format!("{id}/{LINT_KEY_SEGMENT}")) +} + +/// A stable, matchable classification of a [`RegistryError`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RegistryErrorKind { + /// A capability with the same id was already registered. + DuplicateId, +} + +/// The reason a capability registration was rejected. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("a capability with id {id} is already registered")] +#[non_exhaustive] +pub struct RegistryError { + /// A stable classification of the rejection. + kind: RegistryErrorKind, + /// The id whose registration was rejected. + id: CapabilityId, +} + +impl RegistryError { + /// Returns the stable classification of this error. + #[must_use] + pub fn kind(&self) -> RegistryErrorKind { + self.kind + } + + /// Returns the id whose registration was rejected. + #[must_use] + pub fn id(&self) -> &CapabilityId { + &self.id + } +} diff --git a/crates/promptforge-api/src/capabilities/tests.rs b/crates/promptforge-api/src/capabilities/tests.rs new file mode 100644 index 00000000..ef3e9f22 --- /dev/null +++ b/crates/promptforge-api/src/capabilities/tests.rs @@ -0,0 +1,160 @@ +//! Registry tests: duplicate-id rejection, exact lookup, and the +//! registration-time near-duplicate description lint. + +use std::io; +use std::sync::{Arc, Mutex}; + +use shared_promptforge_api::capabilities::{ + Capability, CapabilityError, CapabilityId, Contribution, RunServices, +}; + +use super::{CapabilityRegistry, RegistryErrorKind}; + +/// A minimal capability carrying a fixed id and description. +struct Stub { + id: CapabilityId, + description: String, +} + +impl Capability for Stub { + fn id(&self) -> &CapabilityId { + &self.id + } + fn description(&self) -> &str { + &self.description + } + fn create(&self, services: &RunServices) -> Result { + let _ = services; + Ok(Contribution::default()) + } +} + +/// Parses a test capability id. +fn capability_id(id: &str) -> CapabilityId { + CapabilityId::parse(id).expect("test ids are valid capability ids") +} + +/// Builds a stub capability with a fixed id and description. +fn stub(id: &str, description: &str) -> Arc { + Arc::new(Stub { + id: capability_id(id), + description: description.to_owned(), + }) +} + +/// A shared buffer a fmt subscriber writes lint warnings into. +#[derive(Clone, Default)] +struct Buffer { + bytes: Arc>>, +} + +impl io::Write for Buffer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes + .lock() + .expect("the buffer lock is not poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Runs `f` under a fmt subscriber writing into a shared buffer and +/// returns everything the subscriber captured. +fn captured_warnings(f: impl FnOnce()) -> String { + let buffer = Buffer::default(); + let writer = buffer.clone(); + let subscriber = tracing_subscriber::fmt() + .with_writer(move || writer.clone()) + .with_ansi(false) + .finish(); + tracing::subscriber::with_default(subscriber, f); + let bytes = buffer + .bytes + .lock() + .expect("the buffer lock is not poisoned"); + String::from_utf8_lossy(&bytes).into_owned() +} + +#[test] +fn registering_a_second_capability_under_the_same_id_is_rejected() { + let mut registry = CapabilityRegistry::new(); + registry + .register(stub("promptforge/web", "Web tools.")) + .expect("the first registration succeeds"); + let error = registry + .register(stub("promptforge/web", "Other web tools.")) + .expect_err("a duplicate id is rejected"); + assert_eq!(error.kind(), RegistryErrorKind::DuplicateId); + assert_eq!(error.id(), &capability_id("promptforge/web")); + // The first registration survives the rejected duplicate. + assert_eq!( + registry + .get(&capability_id("promptforge/web")) + .map(|capability| capability.description()), + Some("Web tools.") + ); +} + +#[test] +fn a_registered_capability_resolves_by_exact_id_lookup() { + let mut registry = CapabilityRegistry::new(); + registry + .register(stub("promptforge/web", "Web tools.")) + .expect("the first registration succeeds"); + registry + .register(stub("org.rustalliance/core", "Core tools.")) + .expect("a distinct id registers"); + let found = registry + .get(&capability_id("org.rustalliance/core")) + .expect("the registered id resolves"); + assert_eq!(found.description(), "Core tools."); + assert!(registry.get(&capability_id("promptforge/fs")).is_none()); +} + +#[test] +fn registering_a_near_duplicate_description_fires_the_lint() { + let warnings = captured_warnings(|| { + let mut registry = CapabilityRegistry::new(); + registry + .register(stub("promptforge/web", "Fetch and render a web page.")) + .expect("the first registration succeeds"); + registry + .register(stub("org.rustalliance/web", "Fetch and render a web page.")) + .expect("a near-duplicate description warns without rejecting"); + }); + assert!( + warnings.contains("near-duplicates"), + "the lint fired: {warnings}" + ); + assert!( + warnings.contains("promptforge/web"), + "the warning names the first capability: {warnings}" + ); + assert!( + warnings.contains("org.rustalliance/web"), + "the warning names the second capability: {warnings}" + ); +} + +#[test] +fn distinct_descriptions_do_not_fire_the_lint() { + let warnings = captured_warnings(|| { + let mut registry = CapabilityRegistry::new(); + registry + .register(stub("promptforge/web", "Fetch and render a web page.")) + .expect("the first registration succeeds"); + registry + .register(stub( + "promptforge/fs", + "Read and write files in the run store.", + )) + .expect("a distinct description registers"); + }); + assert!( + !warnings.contains("near-duplicates"), + "no lint fired: {warnings}" + ); +} diff --git a/crates/promptforge-api/src/lib.rs b/crates/promptforge-api/src/lib.rs index d1200b29..e4ff714c 100644 --- a/crates/promptforge-api/src/lib.rs +++ b/crates/promptforge-api/src/lib.rs @@ -69,6 +69,7 @@ //! ``` //! pub(crate) mod cancel; +pub mod capabilities; pub mod client; pub mod debug; mod error; @@ -90,6 +91,7 @@ pub(crate) mod untrusted; pub(crate) use crate::error::{Error, Result}; pub(crate) use crate::tools::NearDuplicateDiagnostic; +pub use crate::capabilities::{CapabilityRegistry, RegistryError, RegistryErrorKind}; pub use crate::client::{CompletionError, CompletionErrorKind}; pub use crate::execute::{ Environment, RunContext, RunError, RunErrorKind, RunLimits, RunResult, SourceLocation, run, diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index c3320780..84c43b3d 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -936,7 +936,7 @@ New `capabilities` module in `shared-promptforge-api` (the crate gains its `shar -### Step 8: CapabilityRegistry +### Step 8: CapabilityRegistry [completed] - Component: capabilities From 81609fdef48d539ddb6496a9a2258fc9a1f273e4 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 21:26:49 -0700 Subject: [PATCH 09/30] Add prepare pass, Requirements report, and per-run VFS A host can now prepare a run against a prompt's declarations: preparation builds the run's filesystem as a fresh router mounting the shared host roots plus a fresh per-run store, then activates every declared capability in declaration order against the deployment's explicit registry. A required capability that is absent or fails to activate is reported as missing, so the run fails until satisfied; an optional one is only ever a log line. Each run's store is its own storage under its own claims table, so two concurrent runs writing the same store path never conflict, while two runs writing the same host file through the shared base still conflict under the base's claims. The preflight report lists only what needs human attention. - `Environment::prepare` builds the per-run filesystem as a fresh router, never an overlay: an overlay shares the base's claims table, which is only correct for two views of the same storage, and concurrent runs' stores are different storage. - `Environment` gains an explicit host-built capability registry as a constructor-set field; the default absence resolves every declared capability as absent. - `Requirements` is the preflight report and lists only what needs human attention: a skipped optional capability is a log line, not a report field. - `Requirements::missing_required` collects each required capability that is absent from the registry or present but failed to activate; activation failures are logged either way, and a failed optional capability contributes nothing to the run. - `vfs_handle` returns the run's prepared filesystem handle, the per-run router hosts extract run output through. - `crates/promptforge-api/tests/suite/prepare.rs` pins the resolution semantics and the claims isolation matrix: two runs writing one store path both proceed, two runs writing one host file through the shared base conflict, and the conflicting write never partially applies. - `contributions` collects each activation's contribution on the context, and nothing reads it yet. - `unmet_requirements` stays empty here; capability activation adds none. - `run` remains the interim stand-in that installs the live resolution inputs and does not call preparation. Design: extends ambient-context @ crates/promptforge-api/src/execute/config.rs::RunContext Design: extends constructor-injection @ crates/promptforge-api/src/execute/environment.rs::Environment Design: new swallowed-exception @ crates/promptforge-api/src/execute/environment.rs::Environment::prepare Design: new surface-growth @ crates/promptforge-api/src/execute/environment.rs::Environment::prepare boundary: pub Design: new surface-growth @ crates/promptforge-api/src/execute/config.rs::RunContext::vfs_handle boundary: pub Design: new surface-growth @ crates/promptforge-api/src/execute/requirements.rs boundary: pub Design: extends surface-growth @ crates/promptforge-api/src/lib.rs boundary: pub Design: new flag-parameter @ crates/promptforge-api/tests/suite/prepare.rs::Fixture::new deps: bool,str Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/src/execute.rs | 5 +- crates/promptforge-api/src/execute/config.rs | 20 + .../src/execute/environment.rs | 109 +++++- .../src/execute/requirements.rs | 61 +++ crates/promptforge-api/src/lib.rs | 3 +- crates/promptforge-api/tests/suite/main.rs | 1 + crates/promptforge-api/tests/suite/prepare.rs | 363 ++++++++++++++++++ ...2026-09-13-1-capabilities-global-naming.md | 2 +- 8 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 crates/promptforge-api/src/execute/requirements.rs create mode 100644 crates/promptforge-api/tests/suite/prepare.rs diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index d0539ff3..62fbe8f6 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -58,7 +58,8 @@ //! The orchestration boundary ([`run`]) lives here; the rest is split into //! focused private children: `error` (the public [`RunError`]), `config` //! ([`RunContext`]/[`RunLimits`]), `environment` (the public -//! [`Environment`]), `context` (the ambient `RunState` run +//! [`Environment`]), `requirements` (the preflight +//! [`Requirements`] report), `context` (the ambient `RunState` run //! state), `gateway` (client acquisition and the live H1 resolution //! inputs), //! `tools` (the nested-inference round), @@ -81,6 +82,7 @@ mod environment; mod error; mod gateway; pub(crate) mod protocol; +mod requirements; mod scheduler; mod scope; mod section_context; @@ -94,6 +96,7 @@ pub use config::{RunContext, RunLimits}; pub use environment::Environment; pub use error::{RunError, RunErrorKind, SourceLocation}; pub(crate) use gateway::ResolutionContext; +pub use requirements::{RequirementCheck, Requirements, UnmetRequirement}; use context::RunState; use scheduler::Scheduler; diff --git a/crates/promptforge-api/src/execute/config.rs b/crates/promptforge-api/src/execute/config.rs index c3d31d79..cac284b1 100644 --- a/crates/promptforge-api/src/execute/config.rs +++ b/crates/promptforge-api/src/execute/config.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::time::{Duration, SystemTime}; use promptforge_tool_picker::ToolPicker; +use shared_promptforge_api::capabilities::Contribution; use crate::cancel::CancelHandle; use crate::client::{GatewayClient, StreamDelta}; @@ -222,6 +223,11 @@ pub struct RunContext { pub(crate) ui: Option serde_json::Value + Send + Sync>>, pub(crate) on_delta: Option>, pub(crate) vfs: VfsRef, + /// The activated capabilities' contributions, in declaration order. + /// Written by [`Environment::prepare`](super::Environment::prepare); + /// the catalog-assembly step consumes them into the run's tool + /// catalog. + pub(crate) contributions: Vec, /// The resolution inputs [`Environment::run`](super::Environment::run) /// installs; `None` on a caller-built context, which the free /// [`run`](super::run) treats as capability-free. @@ -248,6 +254,7 @@ impl RunContext { ui: None, on_delta: None, vfs: promptforge_vfs::empty(), + contributions: Vec::new(), resolution: None, } } @@ -333,6 +340,18 @@ impl RunContext { self } + /// Returns the run's VFS handle. After + /// [`Environment::prepare`](super::Environment::prepare) this is the + /// per-run router - the shared base mounted at `/` plus the run's + /// fresh store - and hosts extract run output through it. + /// + /// Named `vfs_handle` because the builder half already owns + /// [`vfs`](RunContext::vfs). + #[must_use] + pub fn vfs_handle(&self) -> &VfsRef { + &self.vfs + } + /// Returns the run identity shared by every report. #[must_use] pub fn name(&self) -> &str { @@ -369,6 +388,7 @@ impl fmt::Debug for RunContext { .field("ui", &self.ui.is_some()) .field("on_delta", &self.on_delta.is_some()) .field("vfs", &self.vfs) + .field("contributions", &self.contributions) .field("resolution", &self.resolution.is_some()) .finish() } diff --git a/crates/promptforge-api/src/execute/environment.rs b/crates/promptforge-api/src/execute/environment.rs index bb96a0a8..6ea941bd 100644 --- a/crates/promptforge-api/src/execute/environment.rs +++ b/crates/promptforge-api/src/execute/environment.rs @@ -4,7 +4,9 @@ use std::fmt; use std::sync::Arc; use promptforge_tool_picker::ToolPicker; +use shared_promptforge_api::capabilities::{CapabilityId, RunServices}; +use crate::capabilities::CapabilityRegistry; use crate::client::GatewayClient; use crate::model::ModelCatalog; use crate::parser::Prompt; @@ -13,6 +15,7 @@ use crate::tools::ToolCatalog; use super::RunResult; use super::config::{RunContext, RunResolution}; +use super::requirements::Requirements; /// What exists in this deployment and its standing policy. /// @@ -24,10 +27,11 @@ use super::config::{RunContext, RunResolution}; /// Interim state (the interface consolidation step): the environment /// absorbs the retired resolution context's contents - the picker, the /// model catalog, and the tool catalog - as internal fields, and prose -/// binding still works. The registry slot, the per-run router built from -/// `base_vfs`, and the `max_depth` guard all land with the prepare pass -/// in the capability-binding work; until then `base_vfs` and `max_depth` -/// are carried, not consulted. +/// binding still works. [`prepare`](Environment::prepare) resolves the +/// prompt's declared capabilities against the registry and builds the +/// per-run router from `base_vfs`; the `max_depth` guard lands with the +/// sub-run adapter in the deferred prompt-pack work and is carried, not +/// consulted, until then. #[non_exhaustive] pub struct Environment { /// Semantic picker behind executed H1 binds (interim home, absorbed @@ -39,8 +43,11 @@ pub struct Environment { tools: ToolCatalog, /// The deployment's gateway client; a run's own client overrides it. client: Option, - /// Host roots the per-run router mounts; never carries the store - /// mount. Inert until the prepare pass lands. + /// The explicit host-built set of installed capabilities a prompt's + /// frontmatter declarations resolve against at prepare. + registry: Option, + /// Host roots the per-run router mounts at `/`; never carries the + /// store mount (prepare adds a fresh per-run memory backend there). base_vfs: VfsRef, /// Maximum model-orchestrated prompt-tool nesting, copied into every /// run. Inert until the sub-run adapter lands with the prompt-pack. @@ -57,6 +64,7 @@ impl Environment { models: ModelCatalog::default(), tools: ToolCatalog::default(), client: None, + registry: None, base_vfs: VfsRef::builder().build(), max_depth: 3, } @@ -92,8 +100,19 @@ impl Environment { self } - /// Sets the host roots the per-run router mounts. Consulted by the - /// prepare pass when it lands; carried inert until then. + /// Sets the deployment's capability registry: the explicit host-built + /// set of installed capabilities a prompt's frontmatter declarations + /// resolve against at [`prepare`](Environment::prepare). The default + /// (`None`) resolves every declared capability as absent. + #[must_use] + pub fn registry(mut self, registry: CapabilityRegistry) -> Environment { + self.registry = Some(registry); + self + } + + /// Sets the host roots the per-run router mounts at `/`. Consulted by + /// [`prepare`](Environment::prepare); the base must carry host roots + /// only, never the store mount. #[must_use] pub fn base_vfs(mut self, vfs: VfsRef) -> Environment { self.base_vfs = vfs; @@ -109,6 +128,79 @@ impl Environment { self } + /// Enriches the caller-created context against the prompt's + /// declarations: builds the run's VFS and activates every declared + /// capability, reporting what the caller must still satisfy. + /// + /// The per-run VFS is a fresh router mounting the environment's + /// [`base_vfs`](Environment::base_vfs) at `/` plus a fresh memory + /// backend at the store mount - never an overlay: an overlay shares + /// the base's claims table, which is only correct for two views of + /// the same storage, and concurrent runs' stores are different + /// storage. The shared base's own claims table still catches two + /// runs conflicting on one host file under the caller's identity. + /// + /// Declared capabilities resolve against the registry in declaration + /// order. A missing required capability lands in + /// [`Requirements::missing_required`]; an absent optional capability + /// is skipped with a log line. Each present capability is activated + /// with the run's services (its VFS and cancellation handle); an + /// activation failure is logged and the capability contributes + /// nothing to the run - and when the failed capability is required, + /// it also lands in [`Requirements::missing_required`], since the + /// run cannot have what the prompt declared. The contributions ride + /// the context for the catalog-assembly step. + pub fn prepare(&self, prompt: &Prompt, ctx: RunContext) -> (RunContext, Requirements) { + let mut ctx = ctx; + ctx.vfs = VfsRef::builder() + .mount("/", self.base_vfs.clone()) + .mount( + promptforge_vfs::STORE_MOUNT, + shared_vfs::MemoryBackend::new(), + ) + .build(); + let services = RunServices::new(ctx.vfs.clone(), ctx.cancel.clone().unwrap_or_default()); + let mut requirements = Requirements::default(); + for declaration in prompt.frontmatter().capabilities() { + // The parser validated the id's arity and charset at parse + // time, so the checked constructor's validation cannot fail. + let id = CapabilityId::from_validated(&declaration.id().to_string()); + let capability = self + .registry + .as_ref() + .and_then(|registry| registry.get(&id)); + let Some(capability) = capability else { + if declaration.is_optional() { + tracing::info!(capability = %id, "optional capability absent; skipped"); + } else { + requirements.missing_required.push(id); + } + continue; + }; + match capability.create(&services) { + Ok(contribution) => { + tracing::info!(capability = %id, "capability activated"); + ctx.contributions.push(contribution); + } + Err(error) => { + tracing::warn!( + capability = %id, + %error, + "capability activation failed; it contributes nothing to the run" + ); + // A required capability that cannot activate leaves + // the run without something the prompt declared: + // report it like an absent one so the run fails + // until satisfied. + if !declaration.is_optional() { + requirements.missing_required.push(id); + } + } + } + } + (ctx, requirements) + } + /// The zero-burden path: installs the environment's live resolution /// inputs and client default on the context - the interim stand-in for /// the prepare pass, which will also fail unsatisfiable requirements @@ -140,6 +232,7 @@ impl fmt::Debug for Environment { .field("models", &self.models) .field("tools", &"") .field("client", &self.client) + .field("registry", &self.registry) .field("base_vfs", &self.base_vfs) .field("max_depth", &self.max_depth) .finish() diff --git a/crates/promptforge-api/src/execute/requirements.rs b/crates/promptforge-api/src/execute/requirements.rs new file mode 100644 index 00000000..b4409cbc --- /dev/null +++ b/crates/promptforge-api/src/execute/requirements.rs @@ -0,0 +1,61 @@ +//! The preflight report: [`Requirements`]. + +use shared_promptforge_api::capabilities::CapabilityId; + +/// The preflight report: what the caller must still satisfy before the +/// prompt can run. +/// +/// [`Environment::prepare`](super::Environment::prepare) returns one +/// alongside the enriched context. The report lists only what needs human +/// attention: a skipped optional capability is a log line at prepare, not +/// a report field. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct Requirements { + /// The model requirements the filled bindings do not satisfy: the + /// role, which check, and required versus actual (a `min_context` of + /// 200000 against a 32k model; `thinking` against a Never model). + /// Populated by the model fill; capability activation adds none. + pub unmet_requirements: Vec, + /// The required capabilities the run cannot have: absent from the + /// environment's registry, or present but failed to activate. The + /// run fails until every one is satisfied. + pub missing_required: Vec, +} + +impl Requirements { + /// Returns whether nothing blocks the run: no unmet model requirements + /// and no missing required capabilities. + #[must_use] + pub fn is_satisfied(&self) -> bool { + self.unmet_requirements.is_empty() && self.missing_required.is_empty() + } +} + +/// One failed model requirement: the role, which check failed, and what +/// the prompt required versus what the filled model provides. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct UnmetRequirement { + /// The declared role label whose requirement failed. + pub role: String, + /// Which requirement check failed. + pub check: RequirementCheck, + /// What the prompt required (a context minimum of `200000`; the + /// `thinking` keyword). + pub required: String, + /// What the filled model provides (a context of `32000`; a `Never` + /// thinking capability). + pub actual: String, +} + +/// Which model requirement check failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RequirementCheck { + /// The role's context minimum exceeds the filled model's context. + ContextMinimum, + /// A hard keyword (`thinking`, `no-thinking`) the filled model's + /// descriptor does not satisfy. + HardKeyword, +} diff --git a/crates/promptforge-api/src/lib.rs b/crates/promptforge-api/src/lib.rs index e4ff714c..ddb29c9f 100644 --- a/crates/promptforge-api/src/lib.rs +++ b/crates/promptforge-api/src/lib.rs @@ -94,6 +94,7 @@ pub(crate) use crate::tools::NearDuplicateDiagnostic; pub use crate::capabilities::{CapabilityRegistry, RegistryError, RegistryErrorKind}; pub use crate::client::{CompletionError, CompletionErrorKind}; pub use crate::execute::{ - Environment, RunContext, RunError, RunErrorKind, RunLimits, RunResult, SourceLocation, run, + Environment, RequirementCheck, Requirements, RunContext, RunError, RunErrorKind, RunLimits, + RunResult, SourceLocation, UnmetRequirement, run, }; pub use crate::parser::{ParseError, ParseErrorKind, Prompt, promptforge_version}; diff --git a/crates/promptforge-api/tests/suite/main.rs b/crates/promptforge-api/tests/suite/main.rs index f1cfd814..118930bd 100644 --- a/crates/promptforge-api/tests/suite/main.rs +++ b/crates/promptforge-api/tests/suite/main.rs @@ -10,6 +10,7 @@ mod execution; mod fanout; mod parsing; +mod prepare; mod shipped; mod support; mod vfs; diff --git a/crates/promptforge-api/tests/suite/prepare.rs b/crates/promptforge-api/tests/suite/prepare.rs new file mode 100644 index 00000000..7ac339cc --- /dev/null +++ b/crates/promptforge-api/tests/suite/prepare.rs @@ -0,0 +1,363 @@ +//! Prepare-pass integration tests: capability resolution against the +//! registry (missing required reported, absent optional skipped and +//! logged), the run's services reaching `create`, activation failure +//! semantics, and the per-run VFS claims isolation matrix. + +use std::io; +use std::sync::{Arc, Mutex}; + +use promptforge_api::capabilities::CapabilityRegistry; +use promptforge_api::execute::{Environment, RunContext}; +use promptforge_api::parser::Prompt; +use shared_promptforge_api::cancel::CancelHandle; +use shared_promptforge_api::capabilities::{ + Capability, CapabilityError, CapabilityId, Contribution, RunServices, +}; +use shared_promptforge_api::observe::NullObserver; +use shared_vfs::{HostBackend, Origin, VfsError, VfsRef}; + +/// A prompt declaring `promptforge/web` as a required capability. +const DECLARES_REQUIRED: &str = concat!( + "---\n", + "name: declares-required\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - promptforge/web\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// A prompt declaring `promptforge/web` as an optional capability. +const DECLARES_OPTIONAL: &str = concat!( + "---\n", + "name: declares-optional\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - ref: promptforge/web\n", + " optional: true\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// A prompt declaring no capabilities at all. +const DECLARES_NOTHING: &str = concat!( + "---\n", + "name: declares-nothing\n", + "description: d\n", + "promptforge: 0\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// Parses a fixture prompt. +fn parse(source: &str, execution: &str) -> Prompt { + Prompt::parse(source, execution, &NullObserver::default()).expect("the fixture prompt parses") +} + +/// What one activation observed: the marker round-trip through the +/// services VFS and the cancellation handle it was handed. +#[derive(Debug)] +struct Activation { + /// The marker read back through the services VFS, when it round-tripped. + marker: Option, + /// The cancellation handle `create` received. + cancel: CancelHandle, +} + +/// A fixture capability recording each activation's services. `fail` +/// turns every activation into a [`CapabilityError`]. +struct Fixture { + id: CapabilityId, + description: String, + fail: bool, + activations: Arc>>, +} + +impl Fixture { + /// Builds a fixture capability registered under `id`. + fn new(id: &str, fail: bool) -> (Arc, Arc>>) { + let activations = Arc::new(Mutex::new(Vec::new())); + let fixture = Arc::new(Fixture { + id: CapabilityId::parse(id).expect("the fixture id is valid"), + description: format!("The {id} fixture capability."), + fail, + activations: Arc::clone(&activations), + }); + (fixture, activations) + } +} + +impl Capability for Fixture { + fn id(&self) -> &CapabilityId { + &self.id + } + fn description(&self) -> &str { + &self.description + } + fn create(&self, services: &RunServices) -> Result { + if self.fail { + return Err(CapabilityError::message("the fixture cannot activate")); + } + let path = format!("{}/activated.txt", promptforge_vfs::STORE_MOUNT); + let access = services + .vfs + .acquire(Origin::new("fixture activation")) + .map_err(|error| { + CapabilityError::with_source("the fixture could not acquire", error) + })?; + access + .write(&path, b"active") + .map_err(|error| CapabilityError::with_source("the fixture could not write", error))?; + let marker = access + .read(&path) + .ok() + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()); + self.activations + .lock() + .expect("the activations lock is not poisoned") + .push(Activation { + marker, + cancel: services.cancel.clone(), + }); + Ok(Contribution::default()) + } +} + +/// A shared buffer a fmt subscriber writes log lines into. +#[derive(Clone, Default)] +struct Buffer { + bytes: Arc>>, +} + +impl io::Write for Buffer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes + .lock() + .expect("the buffer lock is not poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Runs `f` under a fmt subscriber writing into a shared buffer and +/// returns everything the subscriber captured. +fn captured_logs(f: impl FnOnce()) -> String { + let buffer = Buffer::default(); + let writer = buffer.clone(); + let subscriber = tracing_subscriber::fmt() + .with_writer(move || writer.clone()) + .with_ansi(false) + .finish(); + tracing::subscriber::with_default(subscriber, f); + let bytes = buffer + .bytes + .lock() + .expect("the buffer lock is not poisoned"); + String::from_utf8_lossy(&bytes).into_owned() +} + +/// A unique temporary directory that removes itself on drop. The suite has +/// no tempfile dependency; this mirrors shared-vfs's own test helper. +struct TempDir(std::path::PathBuf); + +impl TempDir { + fn new(name: &str) -> TempDir { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the clock is after the epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "promptforge-api-prepare-{}-{unique}-{name}", + std::process::id(), + )); + std::fs::create_dir_all(&dir).expect("the temp dir creates"); + TempDir(dir) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn a_missing_required_capability_is_reported() { + let prompt = parse(DECLARES_REQUIRED, "declares-required"); + let env = Environment::new(); + let (_ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-missing")); + assert!(requirements.unmet_requirements.is_empty()); + assert_eq!( + requirements.missing_required, + [CapabilityId::parse("promptforge/web").expect("the id is valid")] + ); + assert!(!requirements.is_satisfied()); +} + +#[test] +fn an_absent_optional_capability_is_skipped_and_logged() { + let prompt = parse(DECLARES_OPTIONAL, "declares-optional"); + let env = Environment::new(); + let logs = captured_logs(|| { + let (_ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-optional")); + assert!(requirements.missing_required.is_empty()); + assert!(requirements.is_satisfied()); + }); + assert!( + logs.contains("promptforge/web"), + "the skip log line names the capability: {logs}" + ); +} + +#[test] +fn activation_receives_the_runs_own_services() { + let prompt = parse(DECLARES_REQUIRED, "declares-required"); + let (fixture, activations) = Fixture::new("promptforge/web", false); + let mut registry = CapabilityRegistry::new(); + registry.register(fixture).expect("the fixture registers"); + let env = Environment::new().registry(registry); + let cancel = CancelHandle::new(); + let (ctx, requirements) = env.prepare( + &prompt, + RunContext::new("prepare-services").cancel(cancel.clone()), + ); + assert!(requirements.is_satisfied()); + // The host-supplied cancellation handle reached `create` unchanged. + let activations = activations.lock().expect("the lock is not poisoned"); + assert_eq!(activations.len(), 1, "create ran exactly once"); + assert_eq!(activations[0].marker.as_deref(), Some("active")); + assert!(!activations[0].cancel.is_cancelled()); + cancel.cancel(); + assert!( + activations[0].cancel.is_cancelled(), + "the activated handle is the run's own" + ); + drop(activations); + // The services VFS is the run's prepared handle: the activation's + // marker is readable through the context's store mount. + let access = ctx + .vfs_handle() + .acquire(Origin::new("post-prepare read")) + .expect("the prepared handle acquires"); + let marker = format!("{}/activated.txt", promptforge_vfs::STORE_MOUNT); + assert_eq!( + access.read(&marker).expect("the marker persists"), + b"active" + ); +} + +#[test] +fn a_required_activation_failure_is_logged_and_reported() { + let prompt = parse(DECLARES_REQUIRED, "declares-required"); + let (fixture, _activations) = Fixture::new("promptforge/web", true); + let mut registry = CapabilityRegistry::new(); + registry.register(fixture).expect("the fixture registers"); + let env = Environment::new().registry(registry); + let logs = captured_logs(|| { + // A present-but-failing required capability leaves the run + // without something the prompt declared: it is reported like an + // absent one, and the failure is also a log line. + let (_ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-failing")); + assert_eq!( + requirements.missing_required, + [CapabilityId::parse("promptforge/web").expect("the id is valid")] + ); + assert!(!requirements.is_satisfied()); + }); + assert!( + logs.contains("promptforge/web"), + "the failure log line names the capability: {logs}" + ); +} + +#[test] +fn an_optional_activation_failure_is_logged_and_contributes_nothing() { + let prompt = parse(DECLARES_OPTIONAL, "declares-optional"); + let (fixture, _activations) = Fixture::new("promptforge/web", true); + let mut registry = CapabilityRegistry::new(); + registry.register(fixture).expect("the fixture registers"); + let env = Environment::new().registry(registry); + let logs = captured_logs(|| { + // An optional capability that fails to activate is only a log + // line: the prompt declared it could run without. + let (_ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-failing")); + assert!(requirements.is_satisfied()); + }); + assert!( + logs.contains("promptforge/web"), + "the failure log line names the capability: {logs}" + ); +} + +#[test] +fn two_runs_writing_the_same_store_path_do_not_conflict() { + let prompt = parse(DECLARES_NOTHING, "declares-nothing"); + let env = Environment::new(); + let (ctx_a, _) = env.prepare(&prompt, RunContext::new("run-a")); + let (ctx_b, _) = env.prepare(&prompt, RunContext::new("run-b")); + let access_a = ctx_a + .vfs_handle() + .acquire(Origin::new("run-a")) + .expect("run a acquires"); + let access_b = ctx_b + .vfs_handle() + .acquire(Origin::new("run-b")) + .expect("run b acquires"); + let path = format!("{}/paper.md", promptforge_vfs::STORE_MOUNT); + // Both writes proceed while both accesses are live: each run's store + // is its own storage under its own claims table. + access_a.write(&path, b"from a").expect("run a writes"); + access_b.write(&path, b"from b").expect("run b writes"); + assert_eq!(access_a.read(&path).expect("run a reads"), b"from a"); + assert_eq!(access_b.read(&path).expect("run b reads"), b"from b"); +} + +#[test] +fn two_runs_writing_the_same_host_file_through_the_shared_base_conflict() { + let temp = TempDir::new("shared-base"); + let base = VfsRef::builder() + .mount( + "/", + HostBackend::rooted(&temp.0).expect("the temp dir roots the host backend"), + ) + .build(); + let env = Environment::new().base_vfs(base); + let prompt = parse(DECLARES_NOTHING, "declares-nothing"); + let (ctx_a, _) = env.prepare(&prompt, RunContext::new("run-a")); + let (ctx_b, _) = env.prepare(&prompt, RunContext::new("run-b")); + let access_a = ctx_a + .vfs_handle() + .acquire(Origin::new("run-a")) + .expect("run a acquires"); + access_a + .write("/shared.txt", b"from a") + .expect("run a writes the host file"); + let access_b = ctx_b + .vfs_handle() + .acquire(Origin::new("run-b")) + .expect("run b acquires"); + // The shared base's claims table sees two live identities on one path. + let error = access_b + .write("/shared.txt", b"from b") + .expect_err("run b conflicts with run a's live claim"); + assert!( + matches!(error, VfsError::Conflict(_)), + "a determinism violation, not a backend error: {error}" + ); + // The conflicting write never partially applied. + assert_eq!( + std::fs::read(temp.0.join("shared.txt")).expect("run a's write landed on disk"), + b"from a" + ); +} diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 84c43b3d..39dda049 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -946,7 +946,7 @@ New `capabilities` module in `shared-promptforge-api` (the crate gains its `shar -### Step 9: prepare, Requirements, and the per-run VFS +### Step 9: prepare, Requirements, and the per-run VFS [completed] - Component: capabilities From f62844db94c43352c17313478077254309491c3a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 21:54:17 -0700 Subject: [PATCH 10/30] Add ModelBindings with the trivial fill and refusal The prepare pass now journals model satisfaction: every declared role binds to the host's current model, and each role's hard keywords and context minimum are checked against that model's descriptor and reported as required versus actual. The zero-burden run path refuses an unsatisfiable prompt with a model-readable notice naming each gap, since the notice may arrive as tool output when a prompt runs as a sub-run tool. The binding table is general from day one so multi-model satisfaction arrives later as a smarter fill function, never a structural change. The interim resolution inputs and the client default move from the run method into prepare, so a prepared context is fully equipped whether the host drives the free run itself or not. - `ModelBindings` journals the fill as a role-to-identity map plus an identity-to-descriptor table, so handles resolve label to id to descriptor and two roles bound to one model share one descriptor entry. - `RunContext::model` carries the host's current model as a context field set before prepare; growth into a catalog or policy stays a field change, never a signature change. - `Error::RequirementsUnmet` makes the notice the whole Display message, written to be read by a model, and classifies it as neither cancelled nor retryable. - `fill_model_bindings` binds every declared role to the current model and reports context minimums and hard keyword failures with required versus actual; soft keywords are documentation and never checked. - `Environment::run` prepares implicitly and returns a failure carrying the notice when the report is unsatisfied. - `TestStore` now holds its handle in a mutex and can reconnect to the prepared router, so post-run assertions read the store the run actually wrote. - `fill_model_bindings` with no current model fills and checks nothing; the interim Lua-side catalog resolution carries the run. - `RunContext::vfs` set before prepare is discarded on the zero-burden path, where prepare replaces it with the per-run router. Design: new surface-growth @ crates/promptforge-api/src/execute/bindings.rs::ModelBindings boundary: pub Design: new surface-growth @ crates/promptforge-api/src/error.rs::Error::RequirementsUnmet boundary: pub Design: new surface-growth @ crates/promptforge-api/src/execute/config.rs::RunContext::model boundary: pub Design: new pure-function @ crates/promptforge-api/src/execute/environment.rs::thinking_name deps: ThinkingMode Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/src/error.rs | 30 +++ crates/promptforge-api/src/execute.rs | 7 +- .../promptforge-api/src/execute/bindings.rs | 111 +++++++++ crates/promptforge-api/src/execute/config.rs | 74 +++++- .../src/execute/environment.rs | 136 +++++++++-- crates/promptforge-api/src/execute/error.rs | 1 + .../src/execute/requirements.rs | 35 +++ .../src/execute/tests/exec_flow.rs | 28 +-- .../src/execute/tests/input.rs | 2 +- .../src/execute/tests/live_infer.rs | 19 +- .../promptforge-api/src/execute/tests/mod.rs | 62 +++-- .../src/execute/tests/models_loop.rs | 2 +- .../src/execute/tests/scheduler.rs | 12 +- crates/promptforge-api/src/model.rs | 7 +- .../promptforge-api/tests/suite/execution.rs | 2 - crates/promptforge-api/tests/suite/prepare.rs | 230 +++++++++++++++++- crates/promptforge-api/tests/suite/support.rs | 104 ++++++-- crates/promptforge-api/tests/suite/vfs.rs | 94 +++---- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 19 files changed, 788 insertions(+), 170 deletions(-) create mode 100644 crates/promptforge-api/src/execute/bindings.rs diff --git a/crates/promptforge-api/src/error.rs b/crates/promptforge-api/src/error.rs index 968ebcbe..419f7946 100644 --- a/crates/promptforge-api/src/error.rs +++ b/crates/promptforge-api/src/error.rs @@ -487,6 +487,19 @@ pub(crate) enum Error { #[error("unsupported promptforge version: {0} (this build supports major 0)")] UnsupportedVersion(u32), + /// The environment cannot satisfy the prompt's declared requirements: + /// required capabilities are missing, or the filled model fails a + /// declared hard requirement (a context minimum or hard keyword). + /// + /// The notice is the whole message, written to be read by a model: it + /// may arrive as tool output when the prompt runs as a sub-run tool. + #[error("{notice}")] + #[non_exhaustive] + RequirementsUnmet { + /// The model-readable refusal notice, one line per gap. + notice: String, + }, + /// A dispatched [`shared_promptforge_api::tools::Tool`] returned a model-safe failure. /// /// The tool's own [`shared_promptforge_api::tools::ToolError`] is preserved as the @@ -1050,4 +1063,21 @@ mod tests { let run_error = crate::RunError::from(Error::Interrupted); assert!(run_error.location().is_none()); } + + #[test] + fn requirements_unmet_classifies_and_carries_the_notice_as_its_message() { + // Step 10: the refusal notice is the whole Display - it may arrive + // as tool output when the prompt runs as a sub-run tool - and the + // kind classifies it for code. Retrying cannot help: the + // environment, not the transport, is what falls short. + let error = Error::RequirementsUnmet { + notice: "the environment cannot satisfy this prompt:\n- role 'analyst': requires a context of at least 200000 tokens; the current model provides 32000".to_owned(), + }; + let run_error = crate::RunError::from(error); + assert_eq!(run_error.kind(), crate::RunErrorKind::RequirementsUnmet); + assert!(!run_error.is_cancelled()); + assert!(!run_error.is_retryable()); + assert!(run_error.location().is_none()); + assert!(run_error.to_string().contains("analyst")); + } } diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index 62fbe8f6..4b25896f 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -75,6 +75,7 @@ //! Rust-backed model-tool loop behind the section-visible `models.loop`), //! and `support` (shared helpers). +mod bindings; mod config; mod context; mod engine; @@ -92,6 +93,7 @@ mod tool_loop; mod tools; // Public API surface. +pub use bindings::ModelBindings; pub use config::{RunContext, RunLimits}; pub use environment::Environment; pub use error::{RunError, RunErrorKind, SourceLocation}; @@ -130,8 +132,9 @@ pub enum RunResult { /// /// The free `run` receives an already-prepared [`RunContext`] and has /// nothing to prepare from: a context that never passed through -/// [`Environment::run`] runs capability-free (no picker, empty catalogs). -/// Hosts normally go through [`Environment::run`], the zero-burden path. +/// [`Environment::prepare`] runs capability-free (no picker, empty +/// catalogs). Hosts normally go through [`Environment::run`], the +/// zero-burden path. /// /// # Outcomes /// - [`RunResult::Ok`] - the run completed with its final text. diff --git a/crates/promptforge-api/src/execute/bindings.rs b/crates/promptforge-api/src/execute/bindings.rs new file mode 100644 index 00000000..a9393daf --- /dev/null +++ b/crates/promptforge-api/src/execute/bindings.rs @@ -0,0 +1,111 @@ +//! The run's model satisfaction: [`ModelBindings`]. + +use std::collections::BTreeMap; + +use crate::model::{ModelDescriptor, ModelId}; + +/// The run's model satisfaction: which concrete model each declared role +/// is bound to, and the descriptors of every model this run may use. +/// +/// Written by the fill function at +/// [`prepare`](super::Environment::prepare); v1's fill is deliberately +/// trivial - every declared role binds to the context's current model. +/// The structure is general from day one (a table of models and a map of +/// roles) so multi-model satisfaction arrives as a smarter fill function, +/// never a structural change. Handles resolve label -> id -> descriptor. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct ModelBindings { + /// The decision, journaled: role label to the bound model's identity. + roles: BTreeMap, + /// What this run may use: identity to descriptor. + models: BTreeMap, +} + +impl ModelBindings { + /// Binds the role `label` to `model`, recording the descriptor under + /// its identity. The fill function's only writer. + pub(crate) fn bind(&mut self, label: &str, model: ModelDescriptor) { + self.roles.insert(label.to_owned(), model.id().clone()); + self.models.entry(model.id().clone()).or_insert(model); + } + + /// Returns the identity bound to the role `label`, when it was filled. + #[must_use] + pub fn role_id(&self, label: &str) -> Option<&ModelId> { + self.roles.get(label) + } + + /// Resolves a role label all the way to its descriptor: + /// label -> id -> descriptor. + #[must_use] + pub fn resolve(&self, label: &str) -> Option<&ModelDescriptor> { + self.roles.get(label).and_then(|id| self.models.get(id)) + } + + /// Returns the descriptor bound under `id`, when this run may use it. + #[must_use] + pub fn model(&self, id: &ModelId) -> Option<&ModelDescriptor> { + self.models.get(id) + } + + /// Returns the number of bound roles. + #[must_use] + pub fn len(&self) -> usize { + self.roles.len() + } + + /// Returns whether no roles are bound. + #[must_use] + pub fn is_empty(&self) -> bool { + self.roles.is_empty() + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use super::*; + use crate::model::ThinkingMode; + + fn descriptor(name: &str) -> ModelDescriptor { + ModelDescriptor::new( + ModelId::gateway(name).expect("the test id is valid"), + "A test model", + NonZeroU32::new(32_000).expect("the context window is non-zero"), + ThinkingMode::Switchable, + ) + } + + #[test] + fn an_empty_binding_set_resolves_nothing() { + let bindings = ModelBindings::default(); + assert!(bindings.is_empty()); + assert_eq!(bindings.len(), 0); + assert!(bindings.role_id("analyst").is_none()); + assert!(bindings.resolve("analyst").is_none()); + } + + #[test] + fn two_roles_bound_to_one_model_share_one_descriptor_entry() { + // v1's trivial fill: every role binds the same model, and the + // descriptor table holds it once - the seam a smarter fill grows + // into is visible in the shape, not the content. + let model = descriptor("current"); + let mut bindings = ModelBindings::default(); + bindings.bind("analyst", model.clone()); + bindings.bind("triage", model.clone()); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings.role_id("analyst"), Some(model.id())); + assert_eq!(bindings.role_id("triage"), Some(model.id())); + assert_eq!(bindings.resolve("analyst"), Some(&model)); + assert_eq!(bindings.resolve("triage"), Some(&model)); + assert_eq!(bindings.model(model.id()), Some(&model)); + assert!( + bindings + .model(&ModelId::gateway("other").expect("valid")) + .is_none() + ); + } +} diff --git a/crates/promptforge-api/src/execute/config.rs b/crates/promptforge-api/src/execute/config.rs index cac284b1..75322468 100644 --- a/crates/promptforge-api/src/execute/config.rs +++ b/crates/promptforge-api/src/execute/config.rs @@ -12,11 +12,13 @@ use crate::cancel::CancelHandle; use crate::client::{GatewayClient, StreamDelta}; use crate::debug::DebugCapture; use crate::input::InputBroker; -use crate::model::ModelCatalog; +use crate::model::{ModelCatalog, ModelDescriptor}; use crate::observe::{NullObserver, Observer}; use crate::store::VfsRef; use crate::tools::ToolCatalog; +use super::bindings::ModelBindings; + /// Generates one `nz_*` constructor per `NonZero*` type: a `const fn` /// building the wrapper from a compile-time-known non-zero value. macro_rules! nz { @@ -173,9 +175,10 @@ impl Default for RunLimits { } } -/// The live resolution inputs [`Environment::run`](super::Environment::run) -/// installs on a context before the free [`run`](super::run) drives it: the -/// interim stand-in for the prepare pass, absorbing what the retired +/// The live resolution inputs +/// [`Environment::prepare`](super::Environment::prepare) installs on a +/// context before the free [`run`](super::run) drives it: the interim +/// stand-in for the catalog-assembly step, absorbing what the retired /// borrowed resolution context carried (a picker, a model catalog, a tool /// catalog). A context without one runs capability-free. #[derive(Clone, Default)] @@ -223,13 +226,24 @@ pub struct RunContext { pub(crate) ui: Option serde_json::Value + Send + Sync>>, pub(crate) on_delta: Option>, pub(crate) vfs: VfsRef, + /// The run's current model: the host's selection (in Workshop, the + /// dropdown), set before prepare. Input to prepare's fill function, + /// which binds every declared role to it. Grows into a catalog or + /// policy in the deferred multi-model future - a field change, never + /// a signature change. + pub(crate) model: Option, + /// The run's model satisfaction, written by + /// [`Environment::prepare`](super::Environment::prepare)'s fill + /// function: which concrete model each declared role is bound to. + pub(crate) model_bindings: ModelBindings, /// The activated capabilities' contributions, in declaration order. /// Written by [`Environment::prepare`](super::Environment::prepare); /// the catalog-assembly step consumes them into the run's tool /// catalog. pub(crate) contributions: Vec, - /// The resolution inputs [`Environment::run`](super::Environment::run) - /// installs; `None` on a caller-built context, which the free + /// The resolution inputs + /// [`Environment::prepare`](super::Environment::prepare) installs; + /// `None` on a caller-built context, which the free /// [`run`](super::run) treats as capability-free. pub(crate) resolution: Option, } @@ -254,6 +268,8 @@ impl RunContext { ui: None, on_delta: None, vfs: promptforge_vfs::empty(), + model: None, + model_bindings: ModelBindings::default(), contributions: Vec::new(), resolution: None, } @@ -329,11 +345,31 @@ impl RunContext { self } + /// Sets the run's current model: the host's selection (in Workshop, + /// the dropdown). Input to + /// [`Environment::prepare`](super::Environment::prepare)'s fill + /// function, which binds every declared role to it and checks the + /// roles' hard keywords and context minimums against its descriptor. + /// The default (`None`) fills nothing: the interim Lua-side catalog + /// resolution carries the run. + #[must_use] + pub fn model(mut self, model: ModelDescriptor) -> RunContext { + self.model = Some(model); + 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. + /// section's `store` table operates on. The default is the stock + /// handle (`promptforge_vfs::empty()`), a fresh memory backend at the + /// store mount. + /// + /// [`Environment::prepare`](super::Environment::prepare) - and so + /// [`Environment::run`](super::Environment::run) - replaces this + /// handle unconditionally with the per-run router (the shared base + /// mounted at `/` plus the run's fresh store), so a handle set here + /// is discarded on the zero-burden path. Hosts that seed before the + /// run or extract after it go through the prepared handle + /// ([`vfs_handle`](RunContext::vfs_handle)) instead. #[must_use] pub fn vfs(mut self, vfs: VfsRef) -> RunContext { self.vfs = vfs; @@ -352,6 +388,22 @@ impl RunContext { &self.vfs } + /// Returns the run's current model, when the host set one. + #[must_use] + pub fn current_model(&self) -> Option<&ModelDescriptor> { + self.model.as_ref() + } + + /// Returns the run's model satisfaction, written by + /// [`Environment::prepare`](super::Environment::prepare)'s fill + /// function: which concrete model each declared role is bound to, and + /// the descriptors this run may use. Handles resolve + /// label -> id -> descriptor. + #[must_use] + pub fn model_bindings(&self) -> &ModelBindings { + &self.model_bindings + } + /// Returns the run identity shared by every report. #[must_use] pub fn name(&self) -> &str { @@ -388,6 +440,8 @@ impl fmt::Debug for RunContext { .field("ui", &self.ui.is_some()) .field("on_delta", &self.on_delta.is_some()) .field("vfs", &self.vfs) + .field("model", &self.model) + .field("model_bindings", &self.model_bindings) .field("contributions", &self.contributions) .field("resolution", &self.resolution.is_some()) .finish() diff --git a/crates/promptforge-api/src/execute/environment.rs b/crates/promptforge-api/src/execute/environment.rs index 6ea941bd..716b1931 100644 --- a/crates/promptforge-api/src/execute/environment.rs +++ b/crates/promptforge-api/src/execute/environment.rs @@ -3,19 +3,21 @@ use std::fmt; use std::sync::Arc; +use promptforge_parser::ModelKeyword; use promptforge_tool_picker::ToolPicker; use shared_promptforge_api::capabilities::{CapabilityId, RunServices}; use crate::capabilities::CapabilityRegistry; use crate::client::GatewayClient; -use crate::model::ModelCatalog; +use crate::model::{ModelCatalog, ThinkingMode}; use crate::parser::Prompt; use crate::store::VfsRef; use crate::tools::ToolCatalog; use super::RunResult; +use super::bindings::ModelBindings; use super::config::{RunContext, RunResolution}; -use super::requirements::Requirements; +use super::requirements::{RequirementCheck, Requirements, UnmetRequirement}; /// What exists in this deployment and its standing policy. /// @@ -27,11 +29,12 @@ use super::requirements::Requirements; /// Interim state (the interface consolidation step): the environment /// absorbs the retired resolution context's contents - the picker, the /// model catalog, and the tool catalog - as internal fields, and prose -/// binding still works. [`prepare`](Environment::prepare) resolves the -/// prompt's declared capabilities against the registry and builds the -/// per-run router from `base_vfs`; the `max_depth` guard lands with the -/// sub-run adapter in the deferred prompt-pack work and is carried, not -/// consulted, until then. +/// binding still works. [`prepare`](Environment::prepare) installs those +/// inputs on the context, resolves the prompt's declared capabilities +/// against the registry, builds the per-run router from `base_vfs`, and +/// fills the model bindings from the context's current model; the +/// `max_depth` guard lands with the sub-run adapter in the deferred +/// prompt-pack work and is carried, not consulted, until then. #[non_exhaustive] pub struct Environment { /// Semantic picker behind executed H1 binds (interim home, absorbed @@ -129,8 +132,10 @@ impl Environment { } /// Enriches the caller-created context against the prompt's - /// declarations: builds the run's VFS and activates every declared - /// capability, reporting what the caller must still satisfy. + /// declarations: installs the environment's live resolution inputs and + /// client default, builds the run's VFS, activates every declared + /// capability, and fills the model bindings - reporting what the + /// caller must still satisfy. /// /// The per-run VFS is a fresh router mounting the environment's /// [`base_vfs`](Environment::base_vfs) at `/` plus a fresh memory @@ -150,8 +155,31 @@ impl Environment { /// it also lands in [`Requirements::missing_required`], since the /// run cannot have what the prompt declared. The contributions ride /// the context for the catalog-assembly step. + /// + /// Model satisfaction is a fill function over the declared roles, and + /// v1's fill is deliberately trivial: every role binds to the + /// context's current model, and each role's hard keywords + /// (`thinking`, `no-thinking`) and context minimum are CHECKED + /// against its descriptor - reported in + /// [`Requirements::unmet_requirements`] with required versus actual, + /// never shopped for. Soft keywords document author intent. With no + /// current model there is nothing to fill or check, and the interim + /// Lua-side catalog resolution carries the run. pub fn prepare(&self, prompt: &Prompt, ctx: RunContext) -> (RunContext, Requirements) { let mut ctx = ctx; + // The interim resolution inputs (picker, live catalogs) ride the + // environment until prose binding leaves the run path; prepare + // installs them so a prepared context is fully equipped whether + // the host drives the free run itself or goes through + // [`run`](Environment::run). + ctx.resolution = Some(RunResolution { + picker: self.picker.clone(), + models: self.models.clone(), + tools: self.tools.clone(), + }); + if ctx.client.is_none() { + ctx.client.clone_from(&self.client); + } ctx.vfs = VfsRef::builder() .mount("/", self.base_vfs.clone()) .mount( @@ -198,27 +226,93 @@ impl Environment { } } } + ctx.model_bindings = fill_model_bindings(prompt, ctx.model.as_ref(), &mut requirements); (ctx, requirements) } - /// The zero-burden path: installs the environment's live resolution - /// inputs and client default on the context - the interim stand-in for - /// the prepare pass, which will also fail unsatisfiable requirements - /// here - and runs the prompt. + /// The zero-burden path: [prepares](Environment::prepare) implicitly + /// and refuses an unsatisfiable prompt - missing required + /// capabilities, or unmet model requirements - with + /// [`RunResult::Failure`] carrying + /// [`RequirementsUnmet`](crate::RunErrorKind::RequirementsUnmet) and + /// a model-readable notice naming each gap. The notice may arrive as + /// tool output when the prompt runs as a sub-run tool, so it is + /// written for a model to reason about. pub async fn run(&self, prompt: &Prompt, args: &str, ctx: RunContext) -> RunResult { - let mut ctx = ctx; - ctx.resolution = Some(RunResolution { - picker: self.picker.clone(), - models: self.models.clone(), - tools: self.tools.clone(), - }); - if ctx.client.is_none() { - ctx.client = self.client.clone(); + let (ctx, requirements) = self.prepare(prompt, ctx); + if !requirements.is_satisfied() { + return RunResult::Failure(crate::RunError::from(crate::Error::RequirementsUnmet { + notice: requirements.notice(), + })); } super::run(prompt, args, ctx).await } } +/// v1's deliberately trivial fill: binds every declared role to the +/// context's current model and checks each role's hard keywords and +/// context minimum against its descriptor, reporting required versus +/// actual into [`Requirements::unmet_requirements`]. With no current +/// model there is nothing to fill or check. +fn fill_model_bindings( + prompt: &Prompt, + model: Option<&crate::model::ModelDescriptor>, + requirements: &mut Requirements, +) -> ModelBindings { + let mut bindings = ModelBindings::default(); + let Some(model) = model else { + return bindings; + }; + for (label, role) in prompt.frontmatter().models().iter() { + if let Some(minimum) = role.min_context() + && model.context() < minimum + { + requirements.unmet_requirements.push(UnmetRequirement { + role: label.to_owned(), + check: RequirementCheck::ContextMinimum, + required: minimum.to_string(), + actual: model.context().to_string(), + }); + } + for keyword in role.keywords() { + // Soft keywords document author intent; only the hard + // keywords have a descriptor property to check against. + let failed = match keyword { + ModelKeyword::Thinking if model.thinking() == ThinkingMode::Never => { + Some("thinking") + } + ModelKeyword::NoThinking if model.thinking() != ThinkingMode::Never => { + Some("no-thinking") + } + _ => None, + }; + if let Some(required) = failed { + requirements.unmet_requirements.push(UnmetRequirement { + role: label.to_owned(), + check: RequirementCheck::HardKeyword, + required: required.to_owned(), + actual: thinking_name(model.thinking()).to_owned(), + }); + } + } + bindings.bind(label, model.clone()); + } + bindings +} + +/// The thinking capability as a stable word for required-versus-actual +/// reporting. +fn thinking_name(thinking: ThinkingMode) -> &'static str { + match thinking { + ThinkingMode::Never => "Never", + ThinkingMode::Always => "Always", + ThinkingMode::Switchable => "Switchable", + // The vocabulary is closed today; a future mode reports as + // unknown rather than breaking the report. + _ => "unknown", + } +} + impl Default for Environment { fn default() -> Environment { Environment::new() diff --git a/crates/promptforge-api/src/execute/error.rs b/crates/promptforge-api/src/execute/error.rs index 83ac49f0..d89762b9 100644 --- a/crates/promptforge-api/src/execute/error.rs +++ b/crates/promptforge-api/src/execute/error.rs @@ -93,6 +93,7 @@ impl RunError { RunErrorKind::Lua } Error::UnsupportedVersion(_) => RunErrorKind::Version, + Error::RequirementsUnmet { .. } => RunErrorKind::RequirementsUnmet, Error::MissingEnv(_) | Error::InvalidEnv(_) | Error::InvalidConfig(_) diff --git a/crates/promptforge-api/src/execute/requirements.rs b/crates/promptforge-api/src/execute/requirements.rs index b4409cbc..49bcbe63 100644 --- a/crates/promptforge-api/src/execute/requirements.rs +++ b/crates/promptforge-api/src/execute/requirements.rs @@ -30,6 +30,41 @@ impl Requirements { pub fn is_satisfied(&self) -> bool { self.unmet_requirements.is_empty() && self.missing_required.is_empty() } + + /// The refusal notice [`Environment::run`](super::Environment::run) + /// fails with when the report is unsatisfied. + /// + /// Written to be read by a model - concise, factual, self-contained - + /// because it may arrive as tool output when the prompt runs as a + /// sub-run tool. Each line names what is missing or unmet, with + /// required versus actual. + #[must_use] + pub(crate) fn notice(&self) -> String { + // Writing to a String is infallible; the `let _` mirrors the + // crate's established pattern (subst.rs) under the denied + // `unwrap_used`/`expect_used` lints. + use std::fmt::Write as _; + let mut notice = String::from("the environment cannot satisfy this prompt:"); + for id in &self.missing_required { + let _ = write!(notice, "\n- missing required capability: {id}"); + } + for unmet in &self.unmet_requirements { + let line = match unmet.check { + RequirementCheck::ContextMinimum => format!( + "role '{}': requires a context of at least {} tokens; \ + the current model provides {}", + unmet.role, unmet.required, unmet.actual + ), + RequirementCheck::HardKeyword => format!( + "role '{}': requires '{}'; \ + the current model's thinking capability is {}", + unmet.role, unmet.required, unmet.actual + ), + }; + let _ = write!(notice, "\n- {line}"); + } + notice + } } /// One failed model requirement: the role, which check failed, and what diff --git a/crates/promptforge-api/src/execute/tests/exec_flow.rs b/crates/promptforge-api/src/execute/tests/exec_flow.rs index e4e73877..4e28d2b6 100644 --- a/crates/promptforge-api/src/execute/tests/exec_flow.rs +++ b/crates/promptforge-api/src/execute/tests/exec_flow.rs @@ -2288,11 +2288,13 @@ fn now_rfc3339_checked_produces_a_parseable_timestamp() { #[tokio::test] async fn a_mount_less_handle_runs_on_the_defensive_store_overlay() { - // The defensive fallback in `run`: a hand-built VfsRef lacking the store - // mount gets a fresh memory store overlaid for the run, so the run's - // store writes land on the overlay (readable across sections) instead of - // failing for want of the mount, and the caller's backend stays - // untouched. + // The defensive fallback in the free `run`: a hand-built VfsRef + // lacking the store mount gets a fresh memory store overlaid for the + // run, so the run's store writes land on the overlay (readable across + // sections) instead of failing for want of the mount, and the + // caller's backend stays untouched. (`Environment::run` never needs + // the fallback: its prepare pass builds a router that always carries + // the store mount.) let md = flow_prompt!( "# Test prompt\n\n\ ## First\n\n```lua\nstore.write('overlay.txt', 'overlaid')\n```\n\n\ @@ -2300,16 +2302,12 @@ async fn a_mount_less_handle_runs_on_the_defensive_store_overlay() { ); let test = fixture(md); let vfs = VfsRef::new(shared_vfs::MemoryBackend::new()); - let env = Environment::new() - .picker(empty_test_picker()) - .models(test.models.clone()); - let RunResult::Ok(out) = env - .run( - &test.prompt, - "", - RunContext::new(EXECUTION).vfs(vfs.clone()), - ) - .await + let RunResult::Ok(out) = crate::execute::run( + &test.prompt, + "", + RunContext::new(EXECUTION).vfs(vfs.clone()), + ) + .await else { panic!("a mount-less handle gets the defensive memory-store overlay"); }; diff --git a/crates/promptforge-api/src/execute/tests/input.rs b/crates/promptforge-api/src/execute/tests/input.rs index a4eb9260..31e27f2d 100644 --- a/crates/promptforge-api/src/execute/tests/input.rs +++ b/crates/promptforge-api/src/execute/tests/input.rs @@ -37,7 +37,7 @@ fn input_context(prompt: &Prompt, tools: ToolSet, config: &RunContext) -> RunSta let ctx = RunState::new( prompt, "", - &TestStore::new(), + &TestStore::new().vfs(), LuaProgram::empty().expect("the empty chunk compiles"), config, ); diff --git a/crates/promptforge-api/src/execute/tests/live_infer.rs b/crates/promptforge-api/src/execute/tests/live_infer.rs index 1d89856f..6695b673 100644 --- a/crates/promptforge-api/src/execute/tests/live_infer.rs +++ b/crates/promptforge-api/src/execute/tests/live_infer.rs @@ -153,7 +153,6 @@ async fn shared_library_calls_host_apis_at_load_time() { // `log`, and `args` at load. let picker = empty_test_picker(); let models = test_model_catalog(); - let store = TestStore::new(); let source = "---\nname: shared-host-load\ndescription: d\npromptforge: 0\n---\n\n\ # Shared Host Load\n\n\ ```lua shared\n\ @@ -167,14 +166,16 @@ async fn shared_library_calls_host_apis_at_load_time() { .picker(picker) .models(models) .tools(ToolCatalog::default()); - let RunResult::Ok(out) = env - .run( - &prompt, - "load-time args", - to_context(silent()).vfs(store.vfs().clone()), - ) - .await - else { + // The multi-step path: prepare builds the run's own router, and the + // test store wraps the prepared handle so the post-run assertion + // reads what the run actually wrote. + let (ctx, requirements) = env.prepare(&prompt, to_context(silent())); + assert!( + requirements.is_satisfied(), + "the fixture declares nothing: {requirements:?}" + ); + let store = TestStore::from_vfs(ctx.vfs_handle().clone()); + let RunResult::Ok(out) = crate::execute::run(&prompt, "load-time args", ctx).await else { panic!("top-level shared host calls must succeed"); }; diff --git a/crates/promptforge-api/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs index 900e7dd4..4a6d91cc 100644 --- a/crates/promptforge-api/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -213,46 +213,52 @@ struct RunOptions { /// call is what keeps seeding and post-run assertions conflict-free: the /// claims model attributes every operation to a live identity, so a held /// seeder access would meet the run's own identities as a false race. -struct TestStore(VfsRef); - -impl std::ops::Deref for TestStore { - type Target = VfsRef; - - fn deref(&self) -> &VfsRef { - &self.0 - } -} +/// +/// The handle is reconnectable: [`run`]'s prepare pass builds the run's +/// own router (a fresh store backend per run), so the wrapper points the +/// store at the prepared handle before driving, and post-run assertions +/// read what the run actually wrote. +struct TestStore(Mutex); impl TestStore { fn new() -> TestStore { - TestStore(promptforge_vfs::empty()) + TestStore(Mutex::new(promptforge_vfs::empty())) } /// Wraps a caller-built handle - a gated backend, say - in the test /// store's seeding and post-run assertion helpers. fn from_vfs(vfs: VfsRef) -> TestStore { - TestStore(vfs) + TestStore(Mutex::new(vfs)) } /// The handle the run and the context builders take. - fn vfs(&self) -> &VfsRef { - &self.0 + fn vfs(&self) -> VfsRef { + self.0 + .lock() + .expect("the store lock is not poisoned") + .clone() + } + + /// Points the store at the run's prepared handle, so post-run + /// assertions read the store the run actually used. + fn reconnect(&self, vfs: VfsRef) { + *self.0.lock().expect("the store lock is not poisoned") = vfs; } fn read(&self, path: &str) -> std::result::Result { - let access = self - .0 + let vfs = self.vfs(); + let access = vfs .acquire(shared_vfs::Origin::new("TestStore::read")) .map_err(StoreError::backend)?; - self.0.store(&access).read(path) + vfs.store(&access).read(path) } fn glob(&self, pattern: &str) -> std::result::Result, StoreError> { - let access = self - .0 + let vfs = self.vfs(); + let access = vfs .acquire(shared_vfs::Origin::new("TestStore::glob")) .map_err(StoreError::backend)?; - self.0.store(&access).glob(pattern) + vfs.store(&access).glob(pattern) } } @@ -350,16 +356,24 @@ async fn run( .picker(picker) .models(test.models.clone()) .tools(tool_catalog); - let mut ctx = RunContext::new(opts.execution) - .observer(opts.observer) - .vfs(store.vfs().clone()); + let mut ctx = RunContext::new(opts.execution).observer(opts.observer); if let Some(client) = opts.client { ctx = ctx.client(client); } if let Some(debug) = opts.debug { ctx = ctx.debug(debug); } - match env.run(&test.prompt, args, ctx).await { + // The multi-step path: prepare builds the run's own router (a fresh + // store backend per run), so the test store reconnects to the + // prepared handle for its post-run assertions to read what the run + // actually wrote. + let (ctx, requirements) = env.prepare(&test.prompt, ctx); + assert!( + requirements.is_satisfied(), + "fixture prompts declare no capabilities or model roles: {requirements:?}" + ); + store.reconnect(ctx.vfs_handle().clone()); + match super::run(&test.prompt, args, ctx).await { RunResult::Ok(output) => Ok(output), RunResult::Cancelled => Err(Error::Interrupted), RunResult::Failure(error) => Err(Error::from(error)), @@ -406,7 +420,7 @@ async fn run_with_context( let env = Environment::new() .picker(empty_test_picker()) .models(test.models.clone()); - let ctx = configure(RunContext::new(EXECUTION)).vfs(TestStore::new().vfs().clone()); + let ctx = configure(RunContext::new(EXECUTION)).vfs(TestStore::new().vfs()); match env.run(&test.prompt, "", ctx).await { RunResult::Ok(output) => Ok(output), RunResult::Cancelled => Err(RunError::from(Error::Interrupted)), diff --git a/crates/promptforge-api/src/execute/tests/models_loop.rs b/crates/promptforge-api/src/execute/tests/models_loop.rs index d2d344e4..41e27deb 100644 --- a/crates/promptforge-api/src/execute/tests/models_loop.rs +++ b/crates/promptforge-api/src/execute/tests/models_loop.rs @@ -45,7 +45,7 @@ fn loop_context(prompt: &Prompt, tools: ToolSet) -> RunState { let ctx = RunState::new( prompt, "", - &TestStore::new(), + &TestStore::new().vfs(), LuaProgram::empty().expect("the empty chunk compiles"), &RunContext::new(EXECUTION), ); diff --git a/crates/promptforge-api/src/execute/tests/scheduler.rs b/crates/promptforge-api/src/execute/tests/scheduler.rs index 00b08ef7..03a24022 100644 --- a/crates/promptforge-api/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api/src/execute/tests/scheduler.rs @@ -61,7 +61,7 @@ fn scheduler_context_on( let ctx = RunState::new( prompt, "", - store.vfs(), + &store.vfs(), LuaProgram::empty().expect("the empty chunk compiles"), &RunContext::new(EXECUTION).observer(observer), ); @@ -1257,7 +1257,7 @@ fn h1_context_on(prompt: &Prompt, store: &TestStore, observer: Arc RunState::new( prompt, "", - store.vfs(), + &store.vfs(), LuaProgram::empty().expect("the empty chunk compiles"), &RunContext::new(EXECUTION).observer(observer), ) @@ -1864,7 +1864,7 @@ fn scheduler_context_with_limits(prompt: &Prompt, limits: RunLimits) -> RunState let ctx = RunState::new( prompt, "", - &TestStore::new(), + &TestStore::new().vfs(), LuaProgram::empty().expect("the empty chunk compiles"), &RunContext::new(EXECUTION).limits(limits), ); @@ -2145,7 +2145,7 @@ async fn model_required_when_arm_infer_has_no_binding() { let ctx = RunState::new( &prompt, "", - &TestStore::new(), + &TestStore::new().vfs(), shared, &RunContext::new(EXECUTION), ); @@ -2195,7 +2195,7 @@ async fn the_shared_replay_sees_the_arm_item() { let ctx = RunState::new( &prompt, "", - &TestStore::new(), + &TestStore::new().vfs(), shared, &RunContext::new(EXECUTION), ); @@ -2804,7 +2804,7 @@ async fn fatal_arm_aborts_queued_siblings() { let ctx = RunState::new( &prompt, "", - &store, + &store.vfs(), LuaProgram::empty().expect("the empty chunk compiles"), &RunContext::new(EXECUTION) .limits( diff --git a/crates/promptforge-api/src/model.rs b/crates/promptforge-api/src/model.rs index 3df76d60..f18a5ecc 100644 --- a/crates/promptforge-api/src/model.rs +++ b/crates/promptforge-api/src/model.rs @@ -15,12 +15,9 @@ //! completion error types through [`crate::client`]. pub(crate) use promptforge_model_client::model::{ - CompletionOptions, ModelBindOpts, ModelBinding, ModelCatalog, ModelId, ModelResolver, ModelSet, - ModelView, PickerModelResolver, ResolvedModel, + CompletionOptions, ModelBindOpts, ModelBinding, ModelCatalog, ModelDescriptor, ModelId, + ModelResolver, ModelSet, ModelView, PickerModelResolver, ResolvedModel, ThinkingMode, }; -#[cfg(test)] -pub(crate) use promptforge_model_client::model::{ModelDescriptor, ThinkingMode}; - #[cfg(test)] mod tests; diff --git a/crates/promptforge-api/tests/suite/execution.rs b/crates/promptforge-api/tests/suite/execution.rs index 4e9d7d44..6bd50feb 100644 --- a/crates/promptforge-api/tests/suite/execution.rs +++ b/crates/promptforge-api/tests/suite/execution.rs @@ -181,7 +181,6 @@ async fn concurrent_runs_keep_execution_ids_separate() { first_prompt.as_ref(), "first result", &[], - &promptforge_vfs::empty(), RunOptions { execution: FIRST, observer: Arc::clone(&first_recorder) as Arc, @@ -197,7 +196,6 @@ async fn concurrent_runs_keep_execution_ids_separate() { second_prompt.as_ref(), "second result", &[], - &promptforge_vfs::empty(), RunOptions { execution: SECOND, observer: Arc::clone(&second_recorder) as Arc, diff --git a/crates/promptforge-api/tests/suite/prepare.rs b/crates/promptforge-api/tests/suite/prepare.rs index 7ac339cc..b06c703c 100644 --- a/crates/promptforge-api/tests/suite/prepare.rs +++ b/crates/promptforge-api/tests/suite/prepare.rs @@ -1,18 +1,26 @@ //! Prepare-pass integration tests: capability resolution against the //! registry (missing required reported, absent optional skipped and //! logged), the run's services reaching `create`, activation failure -//! semantics, and the per-run VFS claims isolation matrix. +//! semantics, the per-run VFS claims isolation matrix, and model +//! satisfaction - the trivial fill binding every declared role to the +//! context's current model, the hard-keyword and context-minimum checks +//! against its descriptor, and `Environment::run` refusing an +//! unsatisfiable prompt. use std::io; +use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; use promptforge_api::capabilities::CapabilityRegistry; -use promptforge_api::execute::{Environment, RunContext}; +use promptforge_api::execute::{ + Environment, RequirementCheck, RunContext, RunErrorKind, RunResult, +}; use promptforge_api::parser::Prompt; use shared_promptforge_api::cancel::CancelHandle; use shared_promptforge_api::capabilities::{ Capability, CapabilityError, CapabilityId, Contribution, RunServices, }; +use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; use shared_promptforge_api::observe::NullObserver; use shared_vfs::{HostBackend, Origin, VfsError, VfsRef}; @@ -361,3 +369,221 @@ fn two_runs_writing_the_same_host_file_through_the_shared_base_conflict() { b"from a" ); } + +/// A prompt declaring one model role with a hard keyword and a context +/// minimum. +const DECLARES_ANALYST: &str = concat!( + "---\n", + "name: declares-analyst\n", + "description: d\n", + "promptforge: 0\n", + "models:\n", + " analyst:\n", + " keywords: [frontier, thinking]\n", + " min_context: 200000\n", + " description: Deep analysis\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "```lua\n", + "return 'done'\n", + "```\n", +); + +/// A prompt declaring one role per soft keyword: documentation of author +/// intent, never a check. +const DECLARES_SOFT_ROLES: &str = concat!( + "---\n", + "name: declares-soft-roles\n", + "description: d\n", + "promptforge: 0\n", + "models:\n", + " scout:\n", + " keywords: [frontier, fast]\n", + " sprinter:\n", + " keywords: [small, creative, chat]\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "```lua\n", + "return 'done'\n", + "```\n", +); + +/// A prompt declaring one role with the `no-thinking` hard keyword. +const DECLARES_NO_THINKING: &str = concat!( + "---\n", + "name: declares-no-thinking\n", + "description: d\n", + "promptforge: 0\n", + "models:\n", + " triage:\n", + " keywords: [no-thinking]\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "```lua\n", + "return 'done'\n", + "```\n", +); + +/// Builds the host's one current model with the given context window and +/// thinking capability. +fn current_model(context: u32, thinking: ThinkingMode) -> ModelDescriptor { + ModelDescriptor::new( + ModelId::gateway("current").expect("the id is valid"), + "The host's current model", + NonZeroU32::new(context).expect("the context window is non-zero"), + thinking, + ) +} + +#[test] +fn every_declared_role_resolves_to_the_current_model() { + let prompt = parse(DECLARES_SOFT_ROLES, "declares-soft-roles"); + let env = Environment::new(); + let model = current_model(32_000, ThinkingMode::Never); + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("fill").model(model.clone())); + // Soft keywords document intent and the roles declare no minimum: + // nothing is reported. + assert!(requirements.is_satisfied()); + // The trivial fill binds every declared role to the current model, + // and handles resolve label -> id -> descriptor. + let bindings = ctx.model_bindings(); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings.role_id("scout"), Some(model.id())); + assert_eq!(bindings.resolve("scout"), Some(&model)); + assert_eq!(bindings.resolve("sprinter"), Some(&model)); + assert_eq!(bindings.model(model.id()), Some(&model)); + assert!(bindings.resolve("undeclared").is_none()); +} + +#[test] +fn a_context_minimum_above_the_current_models_is_reported() { + let prompt = parse(DECLARES_ANALYST, "declares-analyst"); + let env = Environment::new(); + // min_context 200000 against the model's 32000. + let (_ctx, requirements) = env.prepare( + &prompt, + RunContext::new("fill").model(current_model(32_000, ThinkingMode::Always)), + ); + assert!(!requirements.is_satisfied()); + assert_eq!(requirements.missing_required, []); + let [unmet] = requirements.unmet_requirements.as_slice() else { + panic!( + "exactly one requirement is unmet: {:?}", + requirements.unmet_requirements + ); + }; + assert_eq!(unmet.role, "analyst"); + assert_eq!(unmet.check, RequirementCheck::ContextMinimum); + assert_eq!(unmet.required, "200000"); + assert_eq!(unmet.actual, "32000"); +} + +#[test] +fn a_hard_keyword_the_current_model_fails_is_reported() { + let prompt = parse(DECLARES_ANALYST, "declares-analyst"); + let env = Environment::new(); + // `thinking` against a Never model, with the context minimum met so + // only the keyword check fires. + let (_ctx, requirements) = env.prepare( + &prompt, + RunContext::new("fill").model(current_model(200_000, ThinkingMode::Never)), + ); + let [unmet] = requirements.unmet_requirements.as_slice() else { + panic!( + "exactly one requirement is unmet: {:?}", + requirements.unmet_requirements + ); + }; + assert_eq!(unmet.role, "analyst"); + assert_eq!(unmet.check, RequirementCheck::HardKeyword); + assert_eq!(unmet.required, "thinking"); + assert_eq!(unmet.actual, "Never"); + + let prompt = parse(DECLARES_NO_THINKING, "declares-no-thinking"); + // `no-thinking` against a Switchable model. + let (_ctx, requirements) = env.prepare( + &prompt, + RunContext::new("fill").model(current_model(32_000, ThinkingMode::Switchable)), + ); + let [unmet] = requirements.unmet_requirements.as_slice() else { + panic!( + "exactly one requirement is unmet: {:?}", + requirements.unmet_requirements + ); + }; + assert_eq!(unmet.role, "triage"); + assert_eq!(unmet.check, RequirementCheck::HardKeyword); + assert_eq!(unmet.required, "no-thinking"); + assert_eq!(unmet.actual, "Switchable"); +} + +#[tokio::test] +async fn env_run_refuses_an_unsatisfiable_prompt_with_a_model_readable_notice() { + let prompt = parse(DECLARES_ANALYST, "declares-analyst"); + let env = Environment::new(); + let result = env + .run( + &prompt, + "", + RunContext::new("refuse").model(current_model(32_000, ThinkingMode::Never)), + ) + .await; + let RunResult::Failure(error) = result else { + panic!("an unsatisfiable prompt is refused: {result:?}"); + }; + assert_eq!(error.kind(), RunErrorKind::RequirementsUnmet); + let notice = error.to_string(); + // The notice is written to be read by a model: it names the role, + // each failed check, and required versus actual. + assert!( + notice.contains("analyst"), + "the notice names the role: {notice}" + ); + assert!( + notice.contains("200000") && notice.contains("32000"), + "the notice gives required versus actual context: {notice}" + ); + assert!( + notice.contains("thinking") && notice.contains("Never"), + "the notice gives required versus actual keywords: {notice}" + ); +} + +#[tokio::test] +async fn env_run_refuses_a_missing_required_capability_with_a_notice_naming_it() { + let prompt = parse(DECLARES_REQUIRED, "declares-required"); + // No registry: the declared required capability is absent. + let env = Environment::new(); + let result = env.run(&prompt, "", RunContext::new("refuse-missing")).await; + let RunResult::Failure(error) = result else { + panic!("a prompt missing a required capability is refused: {result:?}"); + }; + assert_eq!(error.kind(), RunErrorKind::RequirementsUnmet); + let notice = error.to_string(); + assert!( + notice.contains("missing required capability: promptforge/web"), + "the notice names the missing capability: {notice}" + ); +} + +#[tokio::test] +async fn env_run_prepares_implicitly_and_runs_a_satisfiable_prompt() { + let prompt = parse(DECLARES_ANALYST, "declares-analyst"); + let env = Environment::new(); + // The zero-burden path: no explicit prepare call, and the declared + // role's requirements are met by the current model. + let result = env + .run( + &prompt, + "", + RunContext::new("implicit").model(current_model(200_000, ThinkingMode::Always)), + ) + .await; + let RunResult::Ok(text) = result else { + panic!("a satisfiable prompt runs through implicit prepare: {result:?}"); + }; + assert_eq!(text, "done"); +} diff --git a/crates/promptforge-api/tests/suite/support.rs b/crates/promptforge-api/tests/suite/support.rs index 2c46f258..f02debec 100644 --- a/crates/promptforge-api/tests/suite/support.rs +++ b/crates/promptforge-api/tests/suite/support.rs @@ -41,13 +41,15 @@ pub(super) struct RunOptions { pub(super) observer: Arc, } -pub(super) async fn run( +/// Prepares a fixture run against a fixture environment (dummy picker, +/// the given tools) and returns the prepared context plus the run's own +/// VFS handle - the prepared router - for seeding before the run and +/// extraction after. +pub(super) fn prepare_run( prompt: &Prompt, - args: &str, tools: &[Arc], - vfs: &VfsRef, opts: RunOptions, -) -> Result { +) -> (RunContext, VfsRef) { let picker = ToolPicker::build_with_model( &promptforge_tool_picker::Model::dummy(), Catalog::default(), @@ -57,16 +59,56 @@ pub(super) async fn run( .expect("empty fixture picker must build"); let tools = ToolCatalog::new(tools).expect("fixture tools are unique"); let env = Environment::new().picker(picker).tools(tools); - let ctx = RunContext::new(opts.execution) - .observer(opts.observer) - .vfs(vfs.clone()); - match env.run(prompt, args, ctx).await { + let ctx = RunContext::new(opts.execution).observer(opts.observer); + let (ctx, requirements) = env.prepare(prompt, ctx); + assert!( + requirements.is_satisfied(), + "fixture prompts declare no capabilities or model roles: {requirements:?}" + ); + let vfs = ctx.vfs_handle().clone(); + (ctx, vfs) +} + +/// Drives a prepared context to its result through the free `run`. +pub(super) async fn drive( + prompt: &Prompt, + args: &str, + ctx: RunContext, +) -> Result { + match promptforge_api::execute::run(prompt, args, ctx).await { RunResult::Ok(text) => Ok(text), RunResult::Cancelled => panic!("offline fixture runs are never cancelled"), RunResult::Failure(error) => Err(error), } } +/// Prepares and runs a fixture prompt in one call. +pub(super) async fn run( + prompt: &Prompt, + args: &str, + tools: &[Arc], + opts: RunOptions, +) -> Result { + let (ctx, _vfs) = prepare_run(prompt, tools, opts); + drive(prompt, args, ctx).await +} + +/// Runs `prompt` over a caller-built handle with no prepare pass: the raw +/// host-handle contract, for tests of custom store backends (a gated or +/// host-rooted store mount the prepare pass would replace with the run's +/// own fresh store). +pub(super) async fn run_unprepared( + prompt: &Prompt, + args: &str, + vfs: VfsRef, + opts: RunOptions, +) -> Result { + let ctx = RunContext::new(opts.execution) + .observer(opts.observer) + .vfs(vfs); + drive(prompt, args, ctx).await +} + /// A synchronized observer shared by concurrent fixture runs. #[derive(Default)] pub(super) struct Recorder(Mutex>); @@ -126,9 +168,11 @@ pub(super) struct FixtureRun { pub(super) store: FixtureStore, } -/// Parses `source` and runs it offline with `args`, no tools, and either the -/// supplied `vfs` or a fresh stock handle, returning the result together -/// with the recorder and store the caller asserts on. +/// Parses `source` and runs it offline with `args` and no tools, returning +/// the result together with the recorder and store the caller asserts on. +/// With `vfs` absent the run goes through prepare and the store is the +/// prepared router's handle; an explicit `vfs` is the raw host-handle +/// contract - no prepare pass, the run uses the handle as-is. pub(super) async fn run_fixture( source: &'static str, name: &'static str, @@ -138,21 +182,33 @@ pub(super) async fn run_fixture( ) -> FixtureRun { let recorder = Arc::new(Recorder::default()); let prompt = parse_execution_fixture(source, name, execution, recorder.as_ref()); - let vfs = vfs.unwrap_or_else(promptforge_vfs::empty); - let result = run( - &prompt, - args, - &[], - &vfs, - RunOptions { - execution, - observer: Arc::clone(&recorder) as Arc, - }, - ) - .await; + let (result, store) = if let Some(vfs) = vfs { + let result = run_unprepared( + &prompt, + args, + vfs.clone(), + RunOptions { + execution, + observer: Arc::clone(&recorder) as Arc, + }, + ) + .await; + (result, vfs) + } else { + let (ctx, vfs) = prepare_run( + &prompt, + &[], + RunOptions { + execution, + observer: Arc::clone(&recorder) as Arc, + }, + ); + let result = drive(&prompt, args, ctx).await; + (result, vfs) + }; FixtureRun { result, recorder, - store: FixtureStore(vfs), + store: FixtureStore(store), } } diff --git a/crates/promptforge-api/tests/suite/vfs.rs b/crates/promptforge-api/tests/suite/vfs.rs index 9dce8898..480097a9 100644 --- a/crates/promptforge-api/tests/suite/vfs.rs +++ b/crates/promptforge-api/tests/suite/vfs.rs @@ -1,14 +1,15 @@ -//! The executor's `VfsRef` host contract: an end-to-end run over the stock -//! handle, and the papergate-shaped seed-run-extract round trip a production -//! host drives with no real files - seed the declared input through -//! `vfs.store()`, run, extract the declared output, and charge a missing -//! output to the prompt's promise as an explicit contract error. +//! The executor's `VfsRef` host contract: an end-to-end run over the +//! prepared handle, and the papergate-shaped seed-run-extract round trip +//! a production host drives with no real files - prepare, seed the +//! declared input through the run's handle, run, extract the declared +//! output, and charge a missing output to the prompt's promise as an +//! explicit contract error. 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}; +use super::support::{RunOptions, drive, parse_execution_fixture, prepare_run, run_fixture}; use crate::support::Recorder; use std::sync::Arc; @@ -71,9 +72,9 @@ fn extract_declared_output(store: &Store, prompt: &Prompt) -> Result impl std::future::Future> { +) -> ( + VfsRef, + impl std::future::Future>, +) { let recorder = Arc::new(Recorder::default()); let prompt = prompt.clone(); - let vfs = vfs.clone(); - async move { - run( - &prompt, - "", - &[], - &vfs, - RunOptions { - execution, - observer: recorder, - }, - ) - .await - } + let (ctx, vfs) = prepare_run( + &prompt, + &[], + RunOptions { + execution, + observer: recorder, + }, + ); + let run = async move { drive(&prompt, "", ctx).await }; + (vfs, run) } #[tokio::test] async fn an_end_to_end_run_threads_one_vfs_ref_through_every_section() { // The store survives the context-clearing section transition: one - // section's write is the next section's read, over the stock handle. + // section's write is the next section's read, over the run's + // prepared handle. let source = "\ ---\nname: vfs-end-to-end\ndescription: d\npromptforge: 0\n---\n\n\ # Title\n\n\ @@ -127,25 +131,23 @@ return store.read('handoff.txt')\n\ ```\n"; let recorder = Arc::new(Recorder::default()); let prompt = parse_execution_fixture(source, "vfs-end-to-end", "vfs-e2e", recorder.as_ref()); - let vfs = promptforge_vfs::empty(); - let result = run( + let (ctx, vfs) = prepare_run( &prompt, - "", &[], - &vfs, RunOptions { execution: "vfs-e2e", observer: recorder, }, - ) - .await - .expect("the run threads the stock handle through both sections"); + ); + let result = drive(&prompt, "", ctx) + .await + .expect("the run threads the prepared handle through both sections"); assert_eq!(result, "across the reset"); // Extraction after the run takes a fresh access: the run's identities // dropped with it, so nothing the run touched can conflict here. let access = vfs - .acquire(Origin::new("stock handle extraction")) - .expect("the stock backend acquires"); + .acquire(Origin::new("prepared handle extraction")) + .expect("the prepared backend acquires"); assert_eq!( vfs.store(&access) .read("handoff.txt") @@ -155,7 +157,7 @@ return store.read('handoff.txt')\n\ } #[tokio::test] -async fn a_host_seeds_and_extracts_through_the_stock_handle_with_no_real_files() { +async fn a_host_seeds_and_extracts_through_the_prepared_handle_with_no_real_files() { let recorder = Arc::new(Recorder::default()); let prompt = parse_execution_fixture( ROUND_TRIP, @@ -163,15 +165,13 @@ async fn a_host_seeds_and_extracts_through_the_stock_handle_with_no_real_files() "vfs-round-trip", recorder.as_ref(), ); - let vfs = promptforge_vfs::empty(); + let (vfs, run) = offline_run(&prompt, "vfs-round-trip"); seed_declared_input(&vfs, &prompt, "the paper body"); - let result = offline_run(&prompt, &vfs, "vfs-round-trip") - .await - .expect("the seeded run executes offline"); + let result = run.await.expect("the seeded run executes offline"); assert_eq!(result, "done"); let access = vfs .acquire(Origin::new("round-trip extraction")) - .expect("the stock backend acquires"); + .expect("the prepared backend acquires"); let report = extract_declared_output(&vfs.store(&access), &prompt) .expect("the run left its promised output"); assert_eq!(report, "report on: the paper body"); @@ -186,17 +186,15 @@ async fn a_missing_declared_output_is_a_contract_error_naming_the_prompts_promis "vfs-missing-output", recorder.as_ref(), ); - let vfs = promptforge_vfs::empty(); + let (vfs, run) = offline_run(&prompt, "vfs-missing-output"); seed_declared_input(&vfs, &prompt, "the paper body"); // The executor does not enforce the declaration; the run succeeds and // the host's extraction is where the broken promise surfaces. - let result = offline_run(&prompt, &vfs, "vfs-missing-output") - .await - .expect("the run itself succeeds"); + let result = run.await.expect("the run itself succeeds"); assert_eq!(result, "read: the paper body"); let access = vfs .acquire(Origin::new("missing-output extraction")) - .expect("the stock backend acquires"); + .expect("the prepared backend acquires"); let error = extract_declared_output(&vfs.store(&access), &prompt) .expect_err("the missing output is a contract error"); assert!( @@ -244,12 +242,14 @@ async fn fanout_interleaving_is_invariant_across_memory_and_host_backends() { // rooted in a temp dir; the result and the stored contents must be // identical. const FANOUT_STORE_WRITES: &str = include_str!("../prompts/execution/fanout-store-writes.md"); + // Both arms drive the raw host-handle contract (no prepare pass), so + // the caller's own backend serves the store mount in each. let memory = run_fixture( FANOUT_STORE_WRITES, "execution/fanout-store-writes.md", "vfs-invariance-memory", "", - None, + Some(promptforge_vfs::empty()), ) .await; let memory_result = memory diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 39dda049..2ec6f2e5 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -956,7 +956,7 @@ New `capabilities` module in `shared-promptforge-api` (the crate gains its `shar -### Step 10: ModelBindings and the trivial fill +### Step 10: ModelBindings and the trivial fill [completed] - Component: binding From e8bae1e79601e6d4d8fa542a755600281ee979b9 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 22:09:08 -0700 Subject: [PATCH 11/30] Assemble the tool catalog and check co-activation conflicts Preparation now assembles the run's tool catalog from the activated capabilities in declaration order, replacing the raw contribution list that rode the context unread. Containment is enforced at assembly: a contributed tool whose identity escapes its contributing capability, repeats an earlier contribution, or advertises a transport-illegal wire name is logged and never admitted, so one bad tool costs only itself. Declared capabilities are also checked pairwise for co-activation conflicts, and a conflicting pair activates neither member, since two filesystem realities cannot share one context. The preflight report carries each conflicting pair, and the refusal notice names both. - `conflicts` attaches co-activation rules at the capability level with a default of none; the check is symmetric, so only one member of a pair needs to name the other. - `assemble_catalog` validates each contributed tool per tool - containment, uniqueness, then wire name - rejecting a violator with a log line rather than failing the run. - `CapabilityConflict` names a conflicting pair in declaration order and is re-exported from the execute module for hosts. - `Environment::prepare` checks present capabilities pairwise before activation, so a conflicting pair activates neither member and lands in the report naming both; the remaining contributions assemble onto the context in declaration order. - `is_satisfied` now also requires an empty conflict list, and the refusal notice gains one line per pair naming both members. - `contains` holds by identity, not prefix text: dropping the tool's last segment must yield exactly the capability's id, so a pack whose name merely extends this one is not contained. - `tools` returns the assembled catalog, empty on a caller-built context that was never prepared. - `contributions` no longer rides the context; the assembled catalog replaces the raw list nothing consumed. Design: extends facade @ crates/promptforge-api/src/execute.rs boundary: pub Design: extends ambient-context @ crates/promptforge-api/src/execute/config.rs::RunContext Design: new surface-growth @ crates/promptforge-api/src/execute/config.rs::RunContext::tools boundary: pub Design: extends surface-growth @ crates/promptforge-api/src/execute/environment.rs::Environment::prepare boundary: pub Design: new swallowed-exception @ crates/promptforge-api/src/execute/environment.rs::assemble_catalog deps: CapabilityId,Contribution Design: extends surface-growth @ crates/promptforge-api/src/execute/requirements.rs boundary: pub Design: new value-object @ crates/promptforge-api/src/execute/requirements.rs::CapabilityConflict boundary: pub Design: extends surface-growth @ crates/shared-promptforge-api/src/capabilities.rs boundary: pub Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/src/execute.rs | 2 +- crates/promptforge-api/src/execute/config.rs | 25 +- .../src/execute/environment.rs | 134 +++++- .../src/execute/requirements.rs | 35 +- crates/promptforge-api/tests/suite/prepare.rs | 395 +++++++++++++++++- .../src/capabilities.rs | 41 +- .../src/capabilities/tests.rs | 20 + ...2026-09-13-1-capabilities-global-naming.md | 2 +- 8 files changed, 623 insertions(+), 31 deletions(-) diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index 4b25896f..18080ead 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -98,7 +98,7 @@ pub use config::{RunContext, RunLimits}; pub use environment::Environment; pub use error::{RunError, RunErrorKind, SourceLocation}; pub(crate) use gateway::ResolutionContext; -pub use requirements::{RequirementCheck, Requirements, UnmetRequirement}; +pub use requirements::{CapabilityConflict, RequirementCheck, Requirements, UnmetRequirement}; use context::RunState; use scheduler::Scheduler; diff --git a/crates/promptforge-api/src/execute/config.rs b/crates/promptforge-api/src/execute/config.rs index 75322468..9b59a76f 100644 --- a/crates/promptforge-api/src/execute/config.rs +++ b/crates/promptforge-api/src/execute/config.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use std::time::{Duration, SystemTime}; use promptforge_tool_picker::ToolPicker; -use shared_promptforge_api::capabilities::Contribution; use crate::cancel::CancelHandle; use crate::client::{GatewayClient, StreamDelta}; @@ -236,11 +235,12 @@ pub struct RunContext { /// [`Environment::prepare`](super::Environment::prepare)'s fill /// function: which concrete model each declared role is bound to. pub(crate) model_bindings: ModelBindings, - /// The activated capabilities' contributions, in declaration order. - /// Written by [`Environment::prepare`](super::Environment::prepare); - /// the catalog-assembly step consumes them into the run's tool - /// catalog. - pub(crate) contributions: Vec, + /// The run's assembled tool catalog: the activated capabilities' + /// contributed tools in declaration order, with tool + /// prefix-containment enforced at assembly. Written by + /// [`Environment::prepare`](super::Environment::prepare); the + /// slot-filling step fills the prompt's tool slots against it. + pub(crate) tools: ToolCatalog, /// The resolution inputs /// [`Environment::prepare`](super::Environment::prepare) installs; /// `None` on a caller-built context, which the free @@ -270,7 +270,7 @@ impl RunContext { vfs: promptforge_vfs::empty(), model: None, model_bindings: ModelBindings::default(), - contributions: Vec::new(), + tools: ToolCatalog::default(), resolution: None, } } @@ -404,6 +404,15 @@ impl RunContext { &self.model_bindings } + /// Returns the run's assembled tool catalog, written by + /// [`Environment::prepare`](super::Environment::prepare) from the + /// activated capabilities' contributions in declaration order. Empty + /// on a caller-built context that was never prepared. + #[must_use] + pub fn tools(&self) -> &ToolCatalog { + &self.tools + } + /// Returns the run identity shared by every report. #[must_use] pub fn name(&self) -> &str { @@ -442,7 +451,7 @@ impl fmt::Debug for RunContext { .field("vfs", &self.vfs) .field("model", &self.model) .field("model_bindings", &self.model_bindings) - .field("contributions", &self.contributions) + .field("tools", &self.tools) .field("resolution", &self.resolution.is_some()) .finish() } diff --git a/crates/promptforge-api/src/execute/environment.rs b/crates/promptforge-api/src/execute/environment.rs index 716b1931..bb8df309 100644 --- a/crates/promptforge-api/src/execute/environment.rs +++ b/crates/promptforge-api/src/execute/environment.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use promptforge_parser::ModelKeyword; use promptforge_tool_picker::ToolPicker; -use shared_promptforge_api::capabilities::{CapabilityId, RunServices}; +use shared_promptforge_api::capabilities::{Capability, CapabilityId, Contribution, RunServices}; +use shared_promptforge_api::tools::Tool; use crate::capabilities::CapabilityRegistry; use crate::client::GatewayClient; @@ -17,7 +18,7 @@ use crate::tools::ToolCatalog; use super::RunResult; use super::bindings::ModelBindings; use super::config::{RunContext, RunResolution}; -use super::requirements::{RequirementCheck, Requirements, UnmetRequirement}; +use super::requirements::{CapabilityConflict, RequirementCheck, Requirements, UnmetRequirement}; /// What exists in this deployment and its standing policy. /// @@ -31,10 +32,12 @@ use super::requirements::{RequirementCheck, Requirements, UnmetRequirement}; /// model catalog, and the tool catalog - as internal fields, and prose /// binding still works. [`prepare`](Environment::prepare) installs those /// inputs on the context, resolves the prompt's declared capabilities -/// against the registry, builds the per-run router from `base_vfs`, and -/// fills the model bindings from the context's current model; the -/// `max_depth` guard lands with the sub-run adapter in the deferred -/// prompt-pack work and is carried, not consulted, until then. +/// against the registry (rejecting co-activation conflicts), assembles +/// the activated contributions into the run's tool catalog, builds the +/// per-run router from `base_vfs`, and fills the model bindings from +/// the context's current model; the `max_depth` guard lands with the +/// sub-run adapter in the deferred prompt-pack work and is carried, not +/// consulted, until then. #[non_exhaustive] pub struct Environment { /// Semantic picker behind executed H1 binds (interim home, absorbed @@ -148,13 +151,20 @@ impl Environment { /// Declared capabilities resolve against the registry in declaration /// order. A missing required capability lands in /// [`Requirements::missing_required`]; an absent optional capability - /// is skipped with a log line. Each present capability is activated - /// with the run's services (its VFS and cancellation handle); an - /// activation failure is logged and the capability contributes - /// nothing to the run - and when the failed capability is required, - /// it also lands in [`Requirements::missing_required`], since the - /// run cannot have what the prompt declared. The contributions ride - /// the context for the catalog-assembly step. + /// is skipped with a log line. Present capabilities are checked for + /// co-activation conflicts (bashkit vs terminal: two filesystem + /// realities, and a context gets one or the other, never both); a + /// conflicting pair activates neither member and lands in + /// [`Requirements::conflicts`] naming both. Each remaining capability + /// is activated with the run's services (its VFS and cancellation + /// handle); an activation failure is logged and the capability + /// contributes nothing to the run - and when the failed capability + /// is required, it also lands in [`Requirements::missing_required`], + /// since the run cannot have what the prompt declared. The activated + /// contributions are assembled into the run's tool catalog in + /// declaration order, with tool prefix-containment enforced at + /// assembly: a contributed tool whose id escapes its capability's id + /// is rejected - logged and never admitted to the catalog. /// /// Model satisfaction is a fill function over the declared roles, and /// v1's fill is deliberately trivial: every role binds to the @@ -189,6 +199,9 @@ impl Environment { .build(); let services = RunServices::new(ctx.vfs.clone(), ctx.cancel.clone().unwrap_or_default()); let mut requirements = Requirements::default(); + // Resolve the declarations against the registry, preserving + // declaration order. + let mut present: Vec<(CapabilityId, Arc, bool)> = Vec::new(); for declaration in prompt.frontmatter().capabilities() { // The parser validated the id's arity and charset at parse // time, so the checked constructor's validation cannot fail. @@ -205,10 +218,41 @@ impl Environment { } continue; }; + present.push((id, Arc::clone(capability), declaration.is_optional())); + } + // Co-activation conflicts are declared by the capabilities + // themselves; the check is symmetric, so only one member of a + // pair needs to name the other. A conflicting pair activates + // neither member and fails preparation naming both. + let mut conflicted = vec![false; present.len()]; + for (i, (first_id, first, _)) in present.iter().enumerate() { + for (j, (second_id, second, _)) in present.iter().enumerate().skip(i + 1) { + if first.conflicts().contains(second_id) || second.conflicts().contains(first_id) { + tracing::warn!( + first = %first_id, + second = %second_id, + "conflicting capabilities declared; neither activates" + ); + requirements.conflicts.push(CapabilityConflict { + first: first_id.clone(), + second: second_id.clone(), + }); + conflicted[i] = true; + conflicted[j] = true; + } + } + } + let mut activated: Vec<(CapabilityId, Contribution)> = Vec::new(); + for ((id, capability, optional), is_conflicted) in + present.iter().zip(conflicted.iter().copied()) + { + if is_conflicted { + continue; + } match capability.create(&services) { Ok(contribution) => { tracing::info!(capability = %id, "capability activated"); - ctx.contributions.push(contribution); + activated.push((id.clone(), contribution)); } Err(error) => { tracing::warn!( @@ -220,12 +264,13 @@ impl Environment { // the run without something the prompt declared: // report it like an absent one so the run fails // until satisfied. - if !declaration.is_optional() { - requirements.missing_required.push(id); + if !*optional { + requirements.missing_required.push(id.clone()); } } } } + ctx.tools = assemble_catalog(&activated); ctx.model_bindings = fill_model_bindings(prompt, ctx.model.as_ref(), &mut requirements); (ctx, requirements) } @@ -249,6 +294,63 @@ impl Environment { } } +/// Assembles the run's tool catalog from the activated capabilities' +/// contributions in declaration order. +/// +/// Containment is total and enforced here: every contributed tool's id +/// must sit under its contributing capability's full id +/// (`namespace/pack/name` for a `namespace/pack` capability). A +/// violating tool - like a repeated id or a transport-illegal wire +/// name - is rejected at assembly: logged and never admitted to the +/// catalog. +fn assemble_catalog(activated: &[(CapabilityId, Contribution)]) -> ToolCatalog { + let mut accepted: Vec> = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for (capability, contribution) in activated { + for tool in &contribution.tools { + let id = tool.id(); + if !capability.contains(&id) { + tracing::warn!( + capability = %capability, + tool = %id, + "contributed tool id escapes its capability's id; rejected at assembly" + ); + continue; + } + if !seen.insert(id.clone()) { + tracing::warn!( + capability = %capability, + tool = %id, + "contributed tool id repeats an earlier contribution; rejected at assembly" + ); + continue; + } + // The catalog is the transport boundary: validate the wire + // name per tool so one bad tool costs only itself. + if let Err(error) = ToolCatalog::new(std::slice::from_ref(tool)) { + tracing::warn!( + capability = %capability, + tool = %id, + %error, + "contributed tool failed catalog validation; rejected at assembly" + ); + continue; + } + accepted.push(Arc::clone(tool)); + } + } + match ToolCatalog::new(&accepted) { + Ok(catalog) => catalog, + Err(error) => { + // Every accepted tool passed containment, uniqueness, and + // wire-name validation above, so this build cannot fail; + // the arm is defensive. + tracing::warn!(%error, "catalog assembly failed after per-tool validation"); + ToolCatalog::default() + } + } +} + /// v1's deliberately trivial fill: binds every declared role to the /// context's current model and checks each role's hard keywords and /// context minimum against its descriptor, reporting required versus diff --git a/crates/promptforge-api/src/execute/requirements.rs b/crates/promptforge-api/src/execute/requirements.rs index 49bcbe63..91f68132 100644 --- a/crates/promptforge-api/src/execute/requirements.rs +++ b/crates/promptforge-api/src/execute/requirements.rs @@ -21,14 +21,24 @@ pub struct Requirements { /// environment's registry, or present but failed to activate. The /// run fails until every one is satisfied. pub missing_required: Vec, + /// The declared co-activation conflicts: pairs of present + /// capabilities that cannot activate in one run (bashkit vs + /// terminal - two filesystem realities, and a context gets one or + /// the other, never both). Neither member of a conflicting pair + /// activates; the run fails until the prompt declares one or the + /// other. + pub conflicts: Vec, } impl Requirements { - /// Returns whether nothing blocks the run: no unmet model requirements - /// and no missing required capabilities. + /// Returns whether nothing blocks the run: no unmet model + /// requirements, no missing required capabilities, and no + /// co-activation conflicts. #[must_use] pub fn is_satisfied(&self) -> bool { - self.unmet_requirements.is_empty() && self.missing_required.is_empty() + self.unmet_requirements.is_empty() + && self.missing_required.is_empty() + && self.conflicts.is_empty() } /// The refusal notice [`Environment::run`](super::Environment::run) @@ -48,6 +58,14 @@ impl Requirements { for id in &self.missing_required { let _ = write!(notice, "\n- missing required capability: {id}"); } + for conflict in &self.conflicts { + let _ = write!( + notice, + "\n- conflicting capabilities: {} and {} cannot be activated \ + together; declare one or the other", + conflict.first, conflict.second + ); + } for unmet in &self.unmet_requirements { let line = match unmet.check { RequirementCheck::ContextMinimum => format!( @@ -67,6 +85,17 @@ impl Requirements { } } +/// One declared co-activation conflict: two present capabilities that +/// cannot activate in one run, named in declaration order. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct CapabilityConflict { + /// The earlier-declared capability. + pub first: CapabilityId, + /// The later-declared capability. + pub second: CapabilityId, +} + /// One failed model requirement: the role, which check failed, and what /// the prompt required versus what the filled model provides. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/promptforge-api/tests/suite/prepare.rs b/crates/promptforge-api/tests/suite/prepare.rs index b06c703c..ccd77d4b 100644 --- a/crates/promptforge-api/tests/suite/prepare.rs +++ b/crates/promptforge-api/tests/suite/prepare.rs @@ -22,6 +22,7 @@ use shared_promptforge_api::capabilities::{ }; use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; use shared_promptforge_api::observe::NullObserver; +use shared_promptforge_api::tools::{Tool, ToolError, ToolId, ToolOutput}; use shared_vfs::{HostBackend, Origin, VfsError, VfsRef}; /// A prompt declaring `promptforge/web` as a required capability. @@ -557,7 +558,9 @@ async fn env_run_refuses_a_missing_required_capability_with_a_notice_naming_it() let prompt = parse(DECLARES_REQUIRED, "declares-required"); // No registry: the declared required capability is absent. let env = Environment::new(); - let result = env.run(&prompt, "", RunContext::new("refuse-missing")).await; + let result = env + .run(&prompt, "", RunContext::new("refuse-missing")) + .await; let RunResult::Failure(error) = result else { panic!("a prompt missing a required capability is refused: {result:?}"); }; @@ -587,3 +590,393 @@ async fn env_run_prepares_implicitly_and_runs_a_satisfiable_prompt() { }; assert_eq!(text, "done"); } + +// Catalog assembly and conflict checks: prepare assembles the activated +// capabilities' contributed tools into the run's catalog in declaration +// order, enforcing tool prefix-containment at assembly, and rejects +// capability co-activation conflicts naming both. + +/// A prompt declaring `promptforge/bashkit` and `promptforge/terminal`, +/// in that order. +const DECLARES_CONFLICTING: &str = concat!( + "---\n", + "name: declares-conflicting\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - promptforge/bashkit\n", + " - promptforge/terminal\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// A prompt declaring `promptforge/web` and `promptforge/fs`, in that +/// order. +const DECLARES_TWO: &str = concat!( + "---\n", + "name: declares-two\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - promptforge/web\n", + " - promptforge/fs\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// A fixture tool: a static id, its name segment as the wire name, and +/// an empty trusted output. +struct FixtureTool { + id: ToolId, +} + +#[async_trait::async_trait] +impl Tool for FixtureTool { + fn id(&self) -> ToolId { + self.id.clone() + } + + fn wire_name(&self) -> &str { + self.id.name() + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Tool trait fixes this return type to &str" + )] + fn description(&self) -> &str { + "A fixture tool." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object", "properties": {}}) + } + + async fn call(&self, _args: serde_json::Value) -> Result { + Ok(ToolOutput::trusted(String::new())) + } +} + +/// A fixture capability contributing tools and declaring co-activation +/// conflicts. +struct ToolFixture { + id: CapabilityId, + conflicts: Vec, + tools: Vec>, +} + +impl ToolFixture { + /// Builds a fixture registered under `id`, contributing `tools` and + /// conflicting with each id in `conflicts`. + fn new(id: &str, conflicts: &[&str], tools: Vec>) -> ToolFixture { + ToolFixture { + id: CapabilityId::parse(id).expect("the fixture id is valid"), + conflicts: conflicts + .iter() + .map(|id| CapabilityId::parse(id).expect("the conflict id is valid")) + .collect(), + tools, + } + } +} + +impl Capability for ToolFixture { + fn id(&self) -> &CapabilityId { + &self.id + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Capability trait fixes this return type to &str" + )] + fn description(&self) -> &str { + "A tool-contributing fixture capability." + } + + fn conflicts(&self) -> &[CapabilityId] { + &self.conflicts + } + + fn create(&self, services: &RunServices) -> Result { + let _ = services; + Ok(Contribution { + tools: self.tools.clone(), + }) + } +} + +/// Builds a fixture tool arc under `id`. +fn fixture_tool(id: &str) -> Arc { + Arc::new(FixtureTool { + id: ToolId::parse(id).expect("the fixture tool id is valid"), + }) +} + +#[test] +fn a_co_activation_conflict_fails_preparation_naming_both() { + let prompt = parse(DECLARES_CONFLICTING, "declares-conflicting"); + // The check is symmetric: the conflict is found whether the earlier- + // or the later-declared capability declares it. + for (bashkit_conflicts, terminal_conflicts) in [ + (vec!["promptforge/terminal"], vec![]), + (vec![], vec!["promptforge/bashkit"]), + ] { + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(ToolFixture::new( + "promptforge/bashkit", + &bashkit_conflicts, + vec![fixture_tool("promptforge/bashkit/run")], + ))) + .expect("bashkit registers"); + registry + .register(Arc::new(ToolFixture::new( + "promptforge/terminal", + &terminal_conflicts, + vec![fixture_tool("promptforge/terminal/run")], + ))) + .expect("terminal registers"); + let env = Environment::new().registry(registry); + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-conflict")); + assert!(!requirements.is_satisfied()); + let [conflict] = requirements.conflicts.as_slice() else { + panic!( + "exactly one conflict is reported: {:?}", + requirements.conflicts + ); + }; + // Both capabilities are named, in declaration order. + assert_eq!(conflict.first.to_string(), "promptforge/bashkit"); + assert_eq!(conflict.second.to_string(), "promptforge/terminal"); + // A context gets one filesystem reality or the other, never + // both: neither member of the conflicting pair activated, so + // neither tool reached the catalog. + assert!(ctx.tools().tools().is_empty()); + } +} + +#[tokio::test] +async fn env_run_refuses_a_conflicting_pair_with_a_notice_naming_both() { + let prompt = parse(DECLARES_CONFLICTING, "declares-conflicting"); + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(ToolFixture::new( + "promptforge/bashkit", + &["promptforge/terminal"], + vec![], + ))) + .expect("bashkit registers"); + registry + .register(Arc::new(ToolFixture::new( + "promptforge/terminal", + &[], + vec![], + ))) + .expect("terminal registers"); + let env = Environment::new().registry(registry); + let result = env + .run(&prompt, "", RunContext::new("refuse-conflict")) + .await; + let RunResult::Failure(error) = result else { + panic!("a conflicting pair is refused: {result:?}"); + }; + assert_eq!(error.kind(), RunErrorKind::RequirementsUnmet); + let notice = error.to_string(); + assert!( + notice.contains("promptforge/bashkit") && notice.contains("promptforge/terminal"), + "the notice names both conflicting capabilities: {notice}" + ); +} + +#[test] +fn a_contributed_tool_outside_the_capabilitys_id_is_rejected_at_assembly() { + let prompt = parse(DECLARES_REQUIRED, "declares-required"); + let good = ToolId::parse("promptforge/web/fetch").expect("the id is valid"); + let stray = ToolId::parse("promptforge/other/fetch").expect("the id is valid"); + let fixture = ToolFixture::new( + "promptforge/web", + &[], + vec![ + fixture_tool("promptforge/web/fetch"), + fixture_tool("promptforge/other/fetch"), + ], + ); + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(fixture)) + .expect("the fixture registers"); + let env = Environment::new().registry(registry); + let logs = captured_logs(|| { + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-containment")); + // Containment is enforced at assembly, not reported: the run is + // satisfiable and the stray tool simply never enters the catalog. + assert!(requirements.is_satisfied()); + let catalog = ctx.tools(); + assert!( + catalog.get(&good).is_some(), + "the contained tool is assembled" + ); + assert!( + catalog.get(&stray).is_none(), + "the containment violation is rejected at assembly" + ); + assert_eq!(catalog.tools().len(), 1); + }); + assert!( + logs.contains("promptforge/other/fetch") && logs.contains("promptforge/web"), + "the rejection log names the capability and the tool: {logs}" + ); +} + +#[test] +fn the_catalog_assembles_contributed_tools_in_declaration_order() { + let prompt = parse(DECLARES_TWO, "declares-two"); + let web = ToolFixture::new( + "promptforge/web", + &[], + vec![ + fixture_tool("promptforge/web/fetch"), + fixture_tool("promptforge/web/search"), + ], + ); + let fs = ToolFixture::new( + "promptforge/fs", + &[], + vec![fixture_tool("promptforge/fs/read")], + ); + let mut registry = CapabilityRegistry::new(); + registry.register(Arc::new(web)).expect("web registers"); + registry.register(Arc::new(fs)).expect("fs registers"); + let env = Environment::new().registry(registry); + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-order")); + assert!(requirements.is_satisfied()); + let ids: Vec = ctx + .tools() + .tools() + .iter() + .map(|tool| tool.id().to_string()) + .collect(); + assert_eq!( + ids, + [ + "promptforge/web/fetch", + "promptforge/web/search", + "promptforge/fs/read" + ], + "declaration order, then contribution order within each capability" + ); +} + +/// A fixture tool whose wire name is transport-illegal: identity is a +/// valid contained id, but the advertised name carries a `/` separator. +struct BadWireTool { + id: ToolId, + wire: String, +} + +#[async_trait::async_trait] +impl Tool for BadWireTool { + fn id(&self) -> ToolId { + self.id.clone() + } + + fn wire_name(&self) -> &str { + &self.wire + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Tool trait fixes this return type to &str" + )] + fn description(&self) -> &str { + "A fixture tool with an illegal wire name." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object", "properties": {}}) + } + + async fn call(&self, _args: serde_json::Value) -> Result { + Ok(ToolOutput::trusted(String::new())) + } +} + +#[test] +fn a_repeated_tool_id_across_contributions_is_rejected_at_assembly() { + let prompt = parse(DECLARES_REQUIRED, "declares-required"); + let repeated = ToolId::parse("promptforge/web/fetch").expect("the id is valid"); + let fixture = ToolFixture::new( + "promptforge/web", + &[], + vec![ + fixture_tool("promptforge/web/fetch"), + fixture_tool("promptforge/web/search"), + // The repeat: one capability contributes the same id twice. + fixture_tool("promptforge/web/fetch"), + ], + ); + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(fixture)) + .expect("the fixture registers"); + let env = Environment::new().registry(registry); + let logs = captured_logs(|| { + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-duplicate")); + // The repeat is rejected at assembly, not reported: the first + // contribution stands and the run is satisfiable. + assert!(requirements.is_satisfied()); + let catalog = ctx.tools(); + assert!(catalog.get(&repeated).is_some()); + assert_eq!( + catalog.tools().len(), + 2, + "the repeated id enters the catalog exactly once" + ); + }); + assert!( + logs.contains("promptforge/web/fetch") && logs.contains("promptforge/web"), + "the rejection log names the capability and the repeated tool: {logs}" + ); +} + +#[test] +fn a_transport_illegal_wire_name_is_rejected_at_assembly() { + let prompt = parse(DECLARES_REQUIRED, "declares-required"); + let bad = ToolId::parse("promptforge/web/fetch").expect("the id is valid"); + let fixture = ToolFixture::new( + "promptforge/web", + &[], + vec![ + Arc::new(BadWireTool { + id: bad.clone(), + wire: "fetch/v2".to_owned(), + }), + fixture_tool("promptforge/web/search"), + ], + ); + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(fixture)) + .expect("the fixture registers"); + let env = Environment::new().registry(registry); + let logs = captured_logs(|| { + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("prepare-wire-name")); + // One bad tool costs only itself: the run is satisfiable and + // the well-formed tool still assembles. + assert!(requirements.is_satisfied()); + let catalog = ctx.tools(); + assert!( + catalog.get(&bad).is_none(), + "the illegal wire name is rejected at assembly" + ); + assert_eq!(catalog.tools().len(), 1); + }); + assert!( + logs.contains("promptforge/web/fetch") && logs.contains("promptforge/web"), + "the rejection log names the capability and the rejected tool: {logs}" + ); +} diff --git a/crates/shared-promptforge-api/src/capabilities.rs b/crates/shared-promptforge-api/src/capabilities.rs index 74ad3981..78f0f0d1 100644 --- a/crates/shared-promptforge-api/src/capabilities.rs +++ b/crates/shared-promptforge-api/src/capabilities.rs @@ -18,7 +18,7 @@ use shared_vfs::VfsRef; use crate::cancel::CancelHandle; use crate::names::{GlobalName, GlobalNameErrorKind}; -use crate::tools::Tool; +use crate::tools::{Tool, ToolId}; #[cfg(test)] mod tests; @@ -113,6 +113,32 @@ impl CapabilityId { pub fn pack(&self) -> &str { self.0.pack() } + + /// Returns whether `tool` lives under this capability's id. + /// + /// Containment is total: a contributed tool's id is always its + /// contributing capability's id plus one name segment + /// (`namespace/pack/name` for a `namespace/pack` capability), so + /// dropping the tool's last segment must yield exactly this id. + /// Prepare enforces containment when the run's catalog is assembled. + /// + /// # Examples + /// + /// ``` + /// use shared_promptforge_api::capabilities::CapabilityId; + /// use shared_promptforge_api::tools::ToolId; + /// + /// let web = CapabilityId::parse("promptforge/web")?; + /// let fetch = ToolId::parse("promptforge/web/fetch")?; + /// let stray = ToolId::parse("promptforge/other/fetch")?; + /// assert!(web.contains(&fetch)); + /// assert!(!web.contains(&stray)); + /// # Ok::<(), Box>(()) + /// ``` + #[must_use] + pub fn contains(&self, tool: &ToolId) -> bool { + tool.capability() == self.0 + } } impl std::fmt::Display for CapabilityId { @@ -249,6 +275,19 @@ pub trait Capability: Send + Sync { /// registration-time near-duplicate lint. fn description(&self) -> &str; + /// Returns the capabilities this one cannot be activated with in one + /// run. + /// + /// Co-activation rules attach at the capability level: bashkit and a + /// terminal are two filesystem realities, and a context gets one or + /// the other, never both. The default is no conflicts. Prepare checks + /// the declared present capabilities pairwise - the check is + /// symmetric, so only one member of a pair needs to name the other - + /// and fails preparation naming both members of a conflicting pair. + fn conflicts(&self) -> &[CapabilityId] { + &[] + } + /// Activates the capability for one run. /// /// Called once per run at prepare time with the run's services. A diff --git a/crates/shared-promptforge-api/src/capabilities/tests.rs b/crates/shared-promptforge-api/src/capabilities/tests.rs index 0da05167..0a03ee42 100644 --- a/crates/shared-promptforge-api/src/capabilities/tests.rs +++ b/crates/shared-promptforge-api/src/capabilities/tests.rs @@ -7,6 +7,7 @@ use super::{ Contribution, RunServices, }; use crate::cancel::CancelHandle; +use crate::tools::ToolId; /// A minimal in-process capability: a static id, no contributed tools, and /// a `create` that refuses a cancelled run so tests can observe the @@ -86,6 +87,25 @@ fn capability_id_exposes_namespace_and_pack() { assert_eq!(id.pack(), "core"); } +#[test] +fn capability_id_contains_exactly_the_tools_under_it() { + let web = CapabilityId::parse("promptforge/web").expect("a static valid id"); + let fetch = ToolId::parse("promptforge/web/fetch").expect("a static valid id"); + assert!(web.contains(&fetch)); + let stray = ToolId::parse("promptforge/other/fetch").expect("a static valid id"); + assert!(!web.contains(&stray)); + // Containment is by identity, not by prefix text: a pack whose name + // merely extends this one is not contained. + let extended = ToolId::parse("promptforge/web2/fetch").expect("a static valid id"); + assert!(!web.contains(&extended)); +} + +#[test] +fn a_capability_declares_no_conflicts_by_default() { + let capability = StubCapability::web(); + assert!(capability.conflicts().is_empty()); +} + #[test] fn capability_id_serializes_as_its_string_form() { let id = CapabilityId::parse("promptforge/web").expect("a static valid id"); diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 2ec6f2e5..f84c518c 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -966,7 +966,7 @@ New `capabilities` module in `shared-promptforge-api` (the crate gains its `shar -### Step 11: Catalog assembly and conflict checks +### Step 11: Catalog assembly and conflict checks [completed] - Component: binding From ddaadcb68d3ec075fc37de27359fc1a7580069ba Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 22:27:05 -0700 Subject: [PATCH 12/30] Fill tool slots and journal ToolBindings at prepare Preflight now fills the prompt's declared tool slots against the assembled catalog and journals every fill into a new bindings record that hosts and evals can inspect. Exact slots fill by identity: a slot whose capability is inactive is reported as missing, while a slot whose capability is active but contributed no such tool is warned and left unfilled rather than failing the run unsatisfiably. Fuzzy slots resolve through the picker re-indexed over the run's own catalog, so the fuzz can only land on a tool an activated capability contributed. The fill helpers moved out of the environment module into a module of their own. - `ToolBindings` journals the decision as two maps, alias to identity and identity to tool, so two aliases bound to one tool share a single tool entry. Handles resolve alias to id to tool, and the model only ever sees the prompt-local alias. - `fill.rs` gathers preflight's fill functions - catalog assembly, tool slot filling, and the trivial model fill - moved unchanged out of the environment module. - `RunContext` carries the journaled tool bindings written during preflight and exposes them read-only to hosts. - `fill_tool_bindings` reports an exact slot whose capability is inactive as missing, warns and skips a tool absent from an active capability, and fills fuzzy slots through the rebuilt picker; duplicate or ambiguous picker outcomes fill nothing. - `fuzzy_fill_picker` skips the re-index entirely when no fuzzy slot is declared, and leaves every fuzzy slot unfilled when the environment has no picker. - `ToolSlot` postures this fill does not model, including the open host-offered posture, leave their alias unbound with a warning. Design: new registry @ crates/promptforge-api/src/execute/bindings.rs::ToolBindings boundary: pub Design: new surface-growth @ crates/promptforge-api/src/execute/config.rs::RunContext::tool_bindings boundary: pub Design: extends facade @ crates/promptforge-api/src/execute.rs Design: new oversized-unit @ crates/promptforge-api/src/execute/fill.rs::fill_tool_bindings deps: CapabilityId,Prompt,Requirements,ToolCatalog,ToolPicker Deferred: the open host-offered posture leaves its alias unbound with a warning Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/src/execute.rs | 3 +- .../promptforge-api/src/execute/bindings.rs | 152 ++++++++- crates/promptforge-api/src/execute/config.rs | 19 +- .../src/execute/environment.rs | 151 ++------ crates/promptforge-api/src/execute/fill.rs | 321 ++++++++++++++++++ crates/promptforge-api/tests/suite/prepare.rs | 246 +++++++++++++- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 7 files changed, 757 insertions(+), 137 deletions(-) create mode 100644 crates/promptforge-api/src/execute/fill.rs diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index 18080ead..4b5da0d0 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -81,6 +81,7 @@ mod context; mod engine; mod environment; mod error; +mod fill; mod gateway; pub(crate) mod protocol; mod requirements; @@ -93,7 +94,7 @@ mod tool_loop; mod tools; // Public API surface. -pub use bindings::ModelBindings; +pub use bindings::{ModelBindings, ToolBindings}; pub use config::{RunContext, RunLimits}; pub use environment::Environment; pub use error::{RunError, RunErrorKind, SourceLocation}; diff --git a/crates/promptforge-api/src/execute/bindings.rs b/crates/promptforge-api/src/execute/bindings.rs index a9393daf..ec94f79b 100644 --- a/crates/promptforge-api/src/execute/bindings.rs +++ b/crates/promptforge-api/src/execute/bindings.rs @@ -1,8 +1,13 @@ -//! The run's model satisfaction: [`ModelBindings`]. +//! The run's journaled bindings: [`ModelBindings`] and [`ToolBindings`]. use std::collections::BTreeMap; +use std::fmt; +use std::sync::Arc; + +use shared_promptforge_api::tools::Tool; use crate::model::{ModelDescriptor, ModelId}; +use crate::tools::ToolId; /// The run's model satisfaction: which concrete model each declared role /// is bound to, and the descriptors of every model this run may use. @@ -62,9 +67,82 @@ impl ModelBindings { } } +/// The run's tool bindings: which concrete tool each declared alias is +/// bound to, and the tools this run may dispatch. +/// +/// Written by [`prepare`](super::Environment::prepare)'s slot fill: +/// exact slots fill by identity against the assembled catalog and fuzzy +/// slots fill through the picker, and every fill is journaled here so +/// hosts and evals see what the fuzz resolved to. The model only ever +/// sees the prompt-local alias, never the global path. Handles resolve +/// alias -> id -> tool. +#[derive(Clone, Default)] +#[non_exhaustive] +pub struct ToolBindings { + /// The decision, journaled: prompt-local alias to the bound tool's + /// identity. + aliases: BTreeMap, + /// What this run may dispatch: identity to tool. + tools: BTreeMap>, +} + +impl ToolBindings { + /// Binds the prompt-local `alias` to `tool`, recording the tool under + /// its identity. The slot fill's only writer. + pub(crate) fn bind(&mut self, alias: &str, tool: Arc) { + self.aliases.insert(alias.to_owned(), tool.id()); + self.tools.entry(tool.id()).or_insert(tool); + } + + /// Returns the identity bound to `alias`, when the slot was filled. + #[must_use] + pub fn alias_id(&self, alias: &str) -> Option<&ToolId> { + self.aliases.get(alias) + } + + /// Resolves a prompt-local alias all the way to its tool: + /// alias -> id -> tool. + #[must_use] + pub fn resolve(&self, alias: &str) -> Option<&Arc> { + self.aliases.get(alias).and_then(|id| self.tools.get(id)) + } + + /// Returns the tool bound under `id`, when this run may dispatch it. + #[must_use] + pub fn tool(&self, id: &ToolId) -> Option<&Arc> { + self.tools.get(id) + } + + /// Returns the number of bound aliases. + #[must_use] + pub fn len(&self) -> usize { + self.aliases.len() + } + + /// Returns whether no aliases are bound. + #[must_use] + pub fn is_empty(&self) -> bool { + self.aliases.is_empty() + } +} + +impl fmt::Debug for ToolBindings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // The tools are trait objects; their identities stand in, and + // the journaled decision (alias to identity) is the content. + f.debug_struct("ToolBindings") + .field("aliases", &self.aliases) + .field("tools", &self.tools.keys().collect::>()) + .finish() + } +} + #[cfg(test)] mod tests { use std::num::NonZeroU32; + use std::sync::Arc; + + use shared_promptforge_api::tools::{ToolError, ToolOutput}; use super::*; use crate::model::ThinkingMode; @@ -108,4 +186,76 @@ mod tests { .is_none() ); } + + /// A fixture tool: a static id and an empty trusted output. + struct FixtureTool { + id: ToolId, + } + + #[async_trait::async_trait] + impl Tool for FixtureTool { + fn id(&self) -> ToolId { + self.id.clone() + } + + fn wire_name(&self) -> &'static str { + "fixture" + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Tool trait fixes this return type to &str" + )] + fn description(&self) -> &str { + "A fixture tool." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object", "properties": {}}) + } + + async fn call( + &self, + _arguments: serde_json::Value, + ) -> std::result::Result { + Ok(ToolOutput::trusted(String::new())) + } + } + + #[test] + fn an_empty_tool_binding_set_resolves_nothing() { + let bindings = ToolBindings::default(); + assert!(bindings.is_empty()); + assert_eq!(bindings.len(), 0); + assert!(bindings.alias_id("fetch").is_none()); + assert!(bindings.resolve("fetch").is_none()); + } + + #[test] + fn two_aliases_bound_to_one_tool_share_one_tool_entry() { + // Two slots may fill to the same tool; the tool table holds it + // once and both aliases resolve alias -> id -> tool. + let id = ToolId::parse("promptforge/web/fetch").expect("the test id is valid"); + let tool: Arc = Arc::new(FixtureTool { id: id.clone() }); + let mut bindings = ToolBindings::default(); + bindings.bind("fetch", Arc::clone(&tool)); + bindings.bind("getter", Arc::clone(&tool)); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings.alias_id("fetch"), Some(&id)); + assert_eq!(bindings.alias_id("getter"), Some(&id)); + assert_eq!( + bindings.resolve("fetch").map(|tool| tool.id()), + Some(id.clone()) + ); + assert_eq!( + bindings.resolve("getter").map(|tool| tool.id()), + Some(id.clone()) + ); + assert!(bindings.tool(&id).is_some()); + assert!( + bindings + .tool(&ToolId::parse("promptforge/web/search").expect("valid")) + .is_none() + ); + } } diff --git a/crates/promptforge-api/src/execute/config.rs b/crates/promptforge-api/src/execute/config.rs index 9b59a76f..48a13809 100644 --- a/crates/promptforge-api/src/execute/config.rs +++ b/crates/promptforge-api/src/execute/config.rs @@ -16,7 +16,7 @@ use crate::observe::{NullObserver, Observer}; use crate::store::VfsRef; use crate::tools::ToolCatalog; -use super::bindings::ModelBindings; +use super::bindings::{ModelBindings, ToolBindings}; /// Generates one `nz_*` constructor per `NonZero*` type: a `const fn` /// building the wrapper from a compile-time-known non-zero value. @@ -241,6 +241,11 @@ pub struct RunContext { /// [`Environment::prepare`](super::Environment::prepare); the /// slot-filling step fills the prompt's tool slots against it. pub(crate) tools: ToolCatalog, + /// The run's tool bindings, written by + /// [`Environment::prepare`](super::Environment::prepare)'s slot + /// fill against the assembled catalog: which concrete tool each + /// declared alias is bound to, with every fuzzy fill journaled. + pub(crate) tool_bindings: ToolBindings, /// The resolution inputs /// [`Environment::prepare`](super::Environment::prepare) installs; /// `None` on a caller-built context, which the free @@ -271,6 +276,7 @@ impl RunContext { model: None, model_bindings: ModelBindings::default(), tools: ToolCatalog::default(), + tool_bindings: ToolBindings::default(), resolution: None, } } @@ -413,6 +419,16 @@ impl RunContext { &self.tools } + /// Returns the run's tool bindings, written by + /// [`Environment::prepare`](super::Environment::prepare)'s slot + /// fill: which concrete tool each declared alias is bound to, with + /// every fuzzy fill journaled. Handles resolve alias -> id -> tool. + /// Empty on a caller-built context that was never prepared. + #[must_use] + pub fn tool_bindings(&self) -> &ToolBindings { + &self.tool_bindings + } + /// Returns the run identity shared by every report. #[must_use] pub fn name(&self) -> &str { @@ -452,6 +468,7 @@ impl fmt::Debug for RunContext { .field("model", &self.model) .field("model_bindings", &self.model_bindings) .field("tools", &self.tools) + .field("tool_bindings", &self.tool_bindings) .field("resolution", &self.resolution.is_some()) .finish() } diff --git a/crates/promptforge-api/src/execute/environment.rs b/crates/promptforge-api/src/execute/environment.rs index bb8df309..0961596f 100644 --- a/crates/promptforge-api/src/execute/environment.rs +++ b/crates/promptforge-api/src/execute/environment.rs @@ -3,22 +3,20 @@ use std::fmt; use std::sync::Arc; -use promptforge_parser::ModelKeyword; use promptforge_tool_picker::ToolPicker; use shared_promptforge_api::capabilities::{Capability, CapabilityId, Contribution, RunServices}; -use shared_promptforge_api::tools::Tool; use crate::capabilities::CapabilityRegistry; use crate::client::GatewayClient; -use crate::model::{ModelCatalog, ThinkingMode}; +use crate::model::ModelCatalog; use crate::parser::Prompt; use crate::store::VfsRef; use crate::tools::ToolCatalog; use super::RunResult; -use super::bindings::ModelBindings; use super::config::{RunContext, RunResolution}; -use super::requirements::{CapabilityConflict, RequirementCheck, Requirements, UnmetRequirement}; +use super::fill::{assemble_catalog, fill_model_bindings, fill_tool_bindings}; +use super::requirements::{CapabilityConflict, Requirements}; /// What exists in this deployment and its standing policy. /// @@ -175,6 +173,19 @@ impl Environment { /// never shopped for. Soft keywords document author intent. With no /// current model there is nothing to fill or check, and the interim /// Lua-side catalog resolution carries the run. + /// + /// Tool slot filling follows catalog assembly: exact slots fill by + /// identity against the run's catalog - an exact path's first two + /// segments name its capability, so a slot whose capability is + /// inactive lands in [`Requirements::missing_required`], while a + /// slot whose capability is active but contributed no such tool is + /// warned and left unfilled - and fuzzy + /// slots fill through the picker re-indexed over the run's catalog, + /// so the fuzz can never resolve to a tool whose capability is + /// inactive. Every fill is journaled into the context's tool + /// bindings; an unfillable optional fuzzy slot skips with a log + /// line, and an unfillable required fuzzy slot is warned and left + /// unfilled (advertising an unfilled alias fails at run time). pub fn prepare(&self, prompt: &Prompt, ctx: RunContext) -> (RunContext, Requirements) { let mut ctx = ctx; // The interim resolution inputs (picker, live catalogs) ride the @@ -271,6 +282,15 @@ impl Environment { } } ctx.tools = assemble_catalog(&activated); + let activated_ids: Vec = + activated.iter().map(|(id, _)| id.clone()).collect(); + ctx.tool_bindings = fill_tool_bindings( + prompt, + &ctx.tools, + &activated_ids, + self.picker.as_deref(), + &mut requirements, + ); ctx.model_bindings = fill_model_bindings(prompt, ctx.model.as_ref(), &mut requirements); (ctx, requirements) } @@ -294,127 +314,6 @@ impl Environment { } } -/// Assembles the run's tool catalog from the activated capabilities' -/// contributions in declaration order. -/// -/// Containment is total and enforced here: every contributed tool's id -/// must sit under its contributing capability's full id -/// (`namespace/pack/name` for a `namespace/pack` capability). A -/// violating tool - like a repeated id or a transport-illegal wire -/// name - is rejected at assembly: logged and never admitted to the -/// catalog. -fn assemble_catalog(activated: &[(CapabilityId, Contribution)]) -> ToolCatalog { - let mut accepted: Vec> = Vec::new(); - let mut seen = std::collections::BTreeSet::new(); - for (capability, contribution) in activated { - for tool in &contribution.tools { - let id = tool.id(); - if !capability.contains(&id) { - tracing::warn!( - capability = %capability, - tool = %id, - "contributed tool id escapes its capability's id; rejected at assembly" - ); - continue; - } - if !seen.insert(id.clone()) { - tracing::warn!( - capability = %capability, - tool = %id, - "contributed tool id repeats an earlier contribution; rejected at assembly" - ); - continue; - } - // The catalog is the transport boundary: validate the wire - // name per tool so one bad tool costs only itself. - if let Err(error) = ToolCatalog::new(std::slice::from_ref(tool)) { - tracing::warn!( - capability = %capability, - tool = %id, - %error, - "contributed tool failed catalog validation; rejected at assembly" - ); - continue; - } - accepted.push(Arc::clone(tool)); - } - } - match ToolCatalog::new(&accepted) { - Ok(catalog) => catalog, - Err(error) => { - // Every accepted tool passed containment, uniqueness, and - // wire-name validation above, so this build cannot fail; - // the arm is defensive. - tracing::warn!(%error, "catalog assembly failed after per-tool validation"); - ToolCatalog::default() - } - } -} - -/// v1's deliberately trivial fill: binds every declared role to the -/// context's current model and checks each role's hard keywords and -/// context minimum against its descriptor, reporting required versus -/// actual into [`Requirements::unmet_requirements`]. With no current -/// model there is nothing to fill or check. -fn fill_model_bindings( - prompt: &Prompt, - model: Option<&crate::model::ModelDescriptor>, - requirements: &mut Requirements, -) -> ModelBindings { - let mut bindings = ModelBindings::default(); - let Some(model) = model else { - return bindings; - }; - for (label, role) in prompt.frontmatter().models().iter() { - if let Some(minimum) = role.min_context() - && model.context() < minimum - { - requirements.unmet_requirements.push(UnmetRequirement { - role: label.to_owned(), - check: RequirementCheck::ContextMinimum, - required: minimum.to_string(), - actual: model.context().to_string(), - }); - } - for keyword in role.keywords() { - // Soft keywords document author intent; only the hard - // keywords have a descriptor property to check against. - let failed = match keyword { - ModelKeyword::Thinking if model.thinking() == ThinkingMode::Never => { - Some("thinking") - } - ModelKeyword::NoThinking if model.thinking() != ThinkingMode::Never => { - Some("no-thinking") - } - _ => None, - }; - if let Some(required) = failed { - requirements.unmet_requirements.push(UnmetRequirement { - role: label.to_owned(), - check: RequirementCheck::HardKeyword, - required: required.to_owned(), - actual: thinking_name(model.thinking()).to_owned(), - }); - } - } - bindings.bind(label, model.clone()); - } - bindings -} - -/// The thinking capability as a stable word for required-versus-actual -/// reporting. -fn thinking_name(thinking: ThinkingMode) -> &'static str { - match thinking { - ThinkingMode::Never => "Never", - ThinkingMode::Always => "Always", - ThinkingMode::Switchable => "Switchable", - // The vocabulary is closed today; a future mode reports as - // unknown rather than breaking the report. - _ => "unknown", - } -} - impl Default for Environment { fn default() -> Environment { Environment::new() diff --git a/crates/promptforge-api/src/execute/fill.rs b/crates/promptforge-api/src/execute/fill.rs new file mode 100644 index 00000000..52417645 --- /dev/null +++ b/crates/promptforge-api/src/execute/fill.rs @@ -0,0 +1,321 @@ +//! Prepare's fill functions: catalog assembly from the activated +//! capabilities' contributions, tool slot filling against the assembled +//! catalog, and the trivial model fill. + +use std::sync::Arc; + +use promptforge_parser::{FuzzySlot, ModelKeyword, ToolSlot, ToolSlots}; +use promptforge_tool_picker::{Catalog as PickerCatalog, Outcome, ToolDescriptor, ToolPicker}; +use shared_promptforge_api::capabilities::{CapabilityId, Contribution}; +use shared_promptforge_api::tools::Tool; + +use crate::model::ThinkingMode; +use crate::parser::Prompt; +use crate::tools::ToolCatalog; + +use super::bindings::{ModelBindings, ToolBindings}; +use super::requirements::{RequirementCheck, Requirements, UnmetRequirement}; + +/// Assembles the run's tool catalog from the activated capabilities' +/// contributions in declaration order. +/// +/// Containment is total and enforced here: every contributed tool's id +/// must sit under its contributing capability's full id +/// (`namespace/pack/name` for a `namespace/pack` capability). A +/// violating tool - like a repeated id or a transport-illegal wire +/// name - is rejected at assembly: logged and never admitted to the +/// catalog. +pub(super) fn assemble_catalog(activated: &[(CapabilityId, Contribution)]) -> ToolCatalog { + let mut accepted: Vec> = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for (capability, contribution) in activated { + for tool in &contribution.tools { + let id = tool.id(); + if !capability.contains(&id) { + tracing::warn!( + capability = %capability, + tool = %id, + "contributed tool id escapes its capability's id; rejected at assembly" + ); + continue; + } + if !seen.insert(id.clone()) { + tracing::warn!( + capability = %capability, + tool = %id, + "contributed tool id repeats an earlier contribution; rejected at assembly" + ); + continue; + } + // The catalog is the transport boundary: validate the wire + // name per tool so one bad tool costs only itself. + if let Err(error) = ToolCatalog::new(std::slice::from_ref(tool)) { + tracing::warn!( + capability = %capability, + tool = %id, + %error, + "contributed tool failed catalog validation; rejected at assembly" + ); + continue; + } + accepted.push(Arc::clone(tool)); + } + } + match ToolCatalog::new(&accepted) { + Ok(catalog) => catalog, + Err(error) => { + // Every accepted tool passed containment, uniqueness, and + // wire-name validation above, so this build cannot fail; + // the arm is defensive. + tracing::warn!(%error, "catalog assembly failed after per-tool validation"); + ToolCatalog::default() + } + } +} + +/// Fills the prompt's declared tool slots against the assembled catalog, +/// journaling every fill into the returned bindings. +/// +/// Exact slots fill by identity: an exact path's first two segments name +/// its capability, so a slot whose capability is inactive (absent from +/// `activated`) lands in [`Requirements::missing_required`] and the run +/// fails until satisfied. A slot whose capability IS active but whose +/// tool is absent from the catalog - the contribution was rejected at +/// assembly, or the capability never contributed that name - is not a +/// missing capability: installing changes nothing. It is warned and +/// left unfilled, and advertising the unfilled alias fails at run time, +/// exactly like an unfillable required fuzzy slot. +/// Fuzzy slots fill through the picker rebuilt over the run's assembled +/// catalog - never the deployment catalog - so the fuzz can only resolve +/// to a tool an activated capability contributed. An unfillable optional +/// fuzzy slot skips with a log line; an unfillable required fuzzy slot +/// is warned and left unfilled, and advertising the unfilled alias fails +/// at run time. +pub(super) fn fill_tool_bindings( + prompt: &Prompt, + catalog: &ToolCatalog, + activated: &[CapabilityId], + picker: Option<&ToolPicker>, + requirements: &mut Requirements, +) -> ToolBindings { + let mut bindings = ToolBindings::default(); + let slots = prompt.frontmatter().tools(); + let run_picker = fuzzy_fill_picker(slots, catalog, picker); + for (alias, slot) in slots.iter() { + match slot { + ToolSlot::Exact(id) => { + if let Some(tool) = catalog.get(id) { + tracing::info!(alias, tool = %id, "tool slot filled"); + bindings.bind(alias, tool); + } else { + // The parser validated the path's arity, so its first + // two segments are a valid capability id. + let capability = CapabilityId::from_validated(&id.capability().to_string()); + if activated.contains(&capability) { + // The capability is active but the tool is not in + // the catalog: the contribution was rejected at + // assembly or never made. Reporting the capability + // as missing would fail the run unsatisfiably - + // installing it changes nothing - so warn and + // leave the alias unbound instead. + tracing::warn!( + alias, + tool = %id, + capability = %capability, + "exact tool slot's capability is active but \ + contributed no such tool; unfilled - \ + advertising the alias fails at run time" + ); + } else { + tracing::warn!( + alias, + tool = %id, + capability = %capability, + "exact tool slot's capability is inactive" + ); + if !requirements.missing_required.contains(&capability) { + requirements.missing_required.push(capability); + } + } + } + } + ToolSlot::Fuzzy(fuzzy) => { + let filled = run_picker + .as_ref() + .and_then(|picker| fill_fuzzy_slot(alias, fuzzy, catalog, picker)); + match filled { + Some(tool) => { + tracing::info!( + alias, + want = fuzzy.want(), + tool = %tool.id(), + "fuzzy tool slot filled" + ); + bindings.bind(alias, tool); + } + None if fuzzy.is_optional() => { + tracing::info!( + alias, + want = fuzzy.want(), + "optional fuzzy tool slot unfilled; skipped" + ); + } + None => { + tracing::warn!( + alias, + want = fuzzy.want(), + "required fuzzy tool slot unfilled; \ + advertising the alias fails at run time" + ); + } + } + } + // The open host-offered posture is deferred; a posture this + // fill does not model leaves its alias unbound. + _ => { + tracing::warn!(alias, "tool slot has an unrecognized posture; unfilled"); + } + } + } + bindings +} + +/// Builds the run's fuzzy-fill picker: the environment picker's loaded +/// model re-indexed over the assembled catalog, so a fuzzy slot resolves +/// only among tools the activated capabilities contributed. Returns +/// `None` - leaving every fuzzy slot unfilled - when no fuzzy slot is +/// declared (the re-index is skipped entirely), when the environment has +/// no picker, or when the re-index fails. +fn fuzzy_fill_picker( + slots: &ToolSlots, + catalog: &ToolCatalog, + picker: Option<&ToolPicker>, +) -> Option { + if !slots + .iter() + .any(|(_, slot)| matches!(slot, ToolSlot::Fuzzy(_))) + { + return None; + } + let Some(picker) = picker else { + tracing::warn!( + "fuzzy tool slots declared but the environment has no picker; they go unfilled" + ); + return None; + }; + let descriptors: Vec = catalog + .tools() + .iter() + .map(|tool| ToolDescriptor::new(tool.id(), tool.description(), tool.parameters_schema())) + .collect(); + match picker.rebuild(PickerCatalog::new(descriptors)) { + Ok(rebuilt) => Some(rebuilt), + Err(error) => { + tracing::warn!(%error, "the run's fuzzy-fill picker failed to build; fuzzy slots go unfilled"); + None + } + } +} + +/// Resolves one fuzzy slot through the run's picker and looks the picked +/// identity up in the assembled catalog (it must be there - the picker +/// indexed exactly those tools). A non-bind outcome or a failed query +/// fills nothing; the caller applies the slot's optionality. +fn fill_fuzzy_slot( + alias: &str, + fuzzy: &FuzzySlot, + catalog: &ToolCatalog, + picker: &ToolPicker, +) -> Option> { + match picker.resolve(fuzzy.want()) { + Ok(Outcome::Bind(descriptor)) => catalog.get(descriptor.id()), + Ok(Outcome::Absent) => None, + Ok(Outcome::Duplicate(candidates) | Outcome::Ambiguous(candidates)) => { + let ids: Vec = candidates + .iter() + .map(|tool| tool.id().to_string()) + .collect(); + tracing::warn!( + alias, + candidates = ?ids, + "fuzzy tool slot matched several tools; unfilled" + ); + None + } + Ok(_) => { + tracing::warn!( + alias, + "the picker reported an unrecognized outcome; unfilled" + ); + None + } + Err(error) => { + tracing::warn!(alias, %error, "the fuzzy fill query failed; unfilled"); + None + } + } +} + +/// v1's deliberately trivial fill: binds every declared role to the +/// context's current model and checks each role's hard keywords and +/// context minimum against its descriptor, reporting required versus +/// actual into [`Requirements::unmet_requirements`]. With no current +/// model there is nothing to fill or check. +pub(super) fn fill_model_bindings( + prompt: &Prompt, + model: Option<&crate::model::ModelDescriptor>, + requirements: &mut Requirements, +) -> ModelBindings { + let mut bindings = ModelBindings::default(); + let Some(model) = model else { + return bindings; + }; + for (label, role) in prompt.frontmatter().models().iter() { + if let Some(minimum) = role.min_context() + && model.context() < minimum + { + requirements.unmet_requirements.push(UnmetRequirement { + role: label.to_owned(), + check: RequirementCheck::ContextMinimum, + required: minimum.to_string(), + actual: model.context().to_string(), + }); + } + for keyword in role.keywords() { + // Soft keywords document author intent; only the hard + // keywords have a descriptor property to check against. + let failed = match keyword { + ModelKeyword::Thinking if model.thinking() == ThinkingMode::Never => { + Some("thinking") + } + ModelKeyword::NoThinking if model.thinking() != ThinkingMode::Never => { + Some("no-thinking") + } + _ => None, + }; + if let Some(required) = failed { + requirements.unmet_requirements.push(UnmetRequirement { + role: label.to_owned(), + check: RequirementCheck::HardKeyword, + required: required.to_owned(), + actual: thinking_name(model.thinking()).to_owned(), + }); + } + } + bindings.bind(label, model.clone()); + } + bindings +} + +/// The thinking capability as a stable word for required-versus-actual +/// reporting. +fn thinking_name(thinking: ThinkingMode) -> &'static str { + match thinking { + ThinkingMode::Never => "Never", + ThinkingMode::Always => "Always", + ThinkingMode::Switchable => "Switchable", + // The vocabulary is closed today; a future mode reports as + // unknown rather than breaking the report. + _ => "unknown", + } +} diff --git a/crates/promptforge-api/tests/suite/prepare.rs b/crates/promptforge-api/tests/suite/prepare.rs index ccd77d4b..0fdc012f 100644 --- a/crates/promptforge-api/tests/suite/prepare.rs +++ b/crates/promptforge-api/tests/suite/prepare.rs @@ -16,6 +16,7 @@ use promptforge_api::execute::{ Environment, RequirementCheck, RunContext, RunErrorKind, RunResult, }; use promptforge_api::parser::Prompt; +use promptforge_tool_picker::{Catalog, Config, ToolDescriptor, ToolPicker}; use shared_promptforge_api::cancel::CancelHandle; use shared_promptforge_api::capabilities::{ Capability, CapabilityError, CapabilityId, Contribution, RunServices, @@ -628,10 +629,13 @@ const DECLARES_TWO: &str = concat!( "Done.\n", ); -/// A fixture tool: a static id, its name segment as the wire name, and -/// an empty trusted output. +/// A fixture tool: a static id and description, its name segment as the +/// wire name, and an empty trusted output. The description matters: the +/// fuzzy slot fill indexes it, so picker-backed tests need a tool whose +/// description says what the tool does. struct FixtureTool { id: ToolId, + description: String, } #[async_trait::async_trait] @@ -644,12 +648,8 @@ impl Tool for FixtureTool { self.id.name() } - #[expect( - clippy::unnecessary_literal_bound, - reason = "the Tool trait fixes this return type to &str" - )] fn description(&self) -> &str { - "A fixture tool." + &self.description } fn parameters_schema(&self) -> serde_json::Value { @@ -713,6 +713,16 @@ impl Capability for ToolFixture { fn fixture_tool(id: &str) -> Arc { Arc::new(FixtureTool { id: ToolId::parse(id).expect("the fixture tool id is valid"), + description: "A fixture tool.".to_owned(), + }) +} + +/// Builds a fixture tool arc under `id` whose description says what the +/// tool does, so the picker's fuzzy fill has real prose to index. +fn described_tool(id: &str, description: &str) -> Arc { + Arc::new(FixtureTool { + id: ToolId::parse(id).expect("the fixture tool id is valid"), + description: description.to_owned(), }) } @@ -980,3 +990,225 @@ fn a_transport_illegal_wire_name_is_rejected_at_assembly() { "the rejection log names the capability and the rejected tool: {logs}" ); } + +// ToolBindings and slot filling: exact slots fill by identity against +// the assembled catalog (an exact path's first two segments name its +// capability, so a slot whose capability is inactive is reported as +// missing), fuzzy slots fill through the picker over the assembled +// catalog with every fill journaled into the run's tool bindings, and +// an unfillable optional fuzzy slot skips with a log line. + +/// A prompt declaring `promptforge/web` and one exact tool slot. +const DECLARES_EXACT_SLOT: &str = concat!( + "---\n", + "name: declares-exact-slot\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - promptforge/web\n", + "tools:\n", + " fetch: promptforge/web/fetch\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// A prompt declaring one exact tool slot whose capability is not +/// declared at all. +const DECLARES_ORPHAN_SLOT: &str = concat!( + "---\n", + "name: declares-orphan-slot\n", + "description: d\n", + "promptforge: 0\n", + "tools:\n", + " fetch: promptforge/web/fetch\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// A prompt declaring `promptforge/web` and one fuzzy tool slot. +const DECLARES_FUZZY_SLOT: &str = concat!( + "---\n", + "name: declares-fuzzy-slot\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - promptforge/web\n", + "tools:\n", + " fetch:\n", + " want: Fetch a web page over HTTP\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// A prompt declaring `promptforge/web` and one optional fuzzy tool slot +/// nothing in the assembled catalog matches. +const DECLARES_OPTIONAL_FUZZY: &str = concat!( + "---\n", + "name: declares-optional-fuzzy\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - promptforge/web\n", + "tools:\n", + " email:\n", + " want: Send an email to the team\n", + " optional: true\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + +/// The one loaded picker model for this test binary: the fuzzy fill +/// rebuilds the environment picker's model over the run's assembled +/// catalog, so the picker must carry real weights. +fn picker_model() -> &'static promptforge_tool_picker::Model { + static MODEL: std::sync::OnceLock = std::sync::OnceLock::new(); + MODEL.get_or_init(|| { + promptforge_tool_picker::Model::load().expect("the compiled-in model must load") + }) +} + +/// Builds the environment's deployment picker over the shared model. Its +/// catalog content is irrelevant to the fill - prepare re-indexes the +/// run's own assembled catalog - but the build needs one entry so the +/// real model, not a dummy, rides along. +fn test_picker() -> ToolPicker { + let catalog = Catalog::new(vec![ToolDescriptor::new( + ToolId::parse("promptforge/web/fetch").expect("the id is valid"), + "Fetch a web page over HTTP", + serde_json::json!({"type": "object", "properties": {}}), + )]); + ToolPicker::build_with_model(picker_model(), catalog, Config::default(), None) + .expect("the test picker builds") +} + +/// Registers `promptforge/web` contributing one described fetch tool. +fn web_registry() -> CapabilityRegistry { + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(ToolFixture::new( + "promptforge/web", + &[], + vec![described_tool( + "promptforge/web/fetch", + "Fetch a web page over HTTP", + )], + ))) + .expect("web registers"); + registry +} + +#[test] +fn an_exact_slot_fills_against_the_assembled_catalog() { + let prompt = parse(DECLARES_EXACT_SLOT, "declares-exact-slot"); + let env = Environment::new().registry(web_registry()); + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("fill-exact")); + assert!(requirements.is_satisfied()); + let id = ToolId::parse("promptforge/web/fetch").expect("the id is valid"); + let bindings = ctx.tool_bindings(); + assert_eq!(bindings.len(), 1); + // Handles resolve alias -> id -> tool. + assert_eq!(bindings.alias_id("fetch"), Some(&id)); + assert_eq!( + bindings.resolve("fetch").map(|tool| tool.id()), + Some(id.clone()) + ); + assert!(bindings.tool(&id).is_some()); + assert!(bindings.resolve("undeclared").is_none()); +} + +#[test] +fn an_exact_slot_whose_capability_is_inactive_is_reported() { + let prompt = parse(DECLARES_ORPHAN_SLOT, "declares-orphan-slot"); + // No registry and no declaration: the slot's capability is inactive. + let env = Environment::new(); + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("fill-orphan")); + // The exact path's first two segments name its capability. + assert_eq!( + requirements.missing_required, + [CapabilityId::parse("promptforge/web").expect("the id is valid")] + ); + assert!(!requirements.is_satisfied()); + assert!(ctx.tool_bindings().is_empty()); +} + +#[test] +fn an_exact_slot_absent_from_an_active_capability_is_not_reported_missing() { + let prompt = parse(DECLARES_EXACT_SLOT, "declares-exact-slot"); + // The capability activates but contributes a different tool: the + // slot's capability is not missing, so the run must not fail + // unsatisfiably - installing changes nothing. + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(ToolFixture::new( + "promptforge/web", + &[], + vec![described_tool( + "promptforge/web/search", + "Search the web", + )], + ))) + .expect("web registers"); + let env = Environment::new().registry(registry); + let logs = captured_logs(|| { + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("fill-absent-tool")); + assert!( + requirements.missing_required.is_empty(), + "an active capability is never reported missing: {:?}", + requirements.missing_required + ); + assert!(requirements.is_satisfied()); + // The alias stays unbound; advertising it fails at run time. + assert!(ctx.tool_bindings().is_empty()); + }); + assert!( + logs.contains("fetch"), + "the warning names the unfilled alias: {logs}" + ); +} + +#[test] +fn a_fuzzy_slot_fills_via_the_picker_and_the_fill_is_journaled() { + let prompt = parse(DECLARES_FUZZY_SLOT, "declares-fuzzy-slot"); + let env = Environment::new() + .registry(web_registry()) + .picker(test_picker()); + let logs = captured_logs(|| { + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("fill-fuzzy")); + assert!(requirements.is_satisfied()); + let id = ToolId::parse("promptforge/web/fetch").expect("the id is valid"); + let bindings = ctx.tool_bindings(); + // The journaled fill: the alias resolves to the picked tool. + assert_eq!(bindings.alias_id("fetch"), Some(&id)); + assert!(bindings.resolve("fetch").is_some()); + }); + assert!( + logs.contains("promptforge/web/fetch"), + "the journal records what the fuzz resolved to: {logs}" + ); +} + +#[test] +fn an_optional_fuzzy_slot_with_no_match_is_skipped_and_logged() { + let prompt = parse(DECLARES_OPTIONAL_FUZZY, "declares-optional-fuzzy"); + let env = Environment::new() + .registry(web_registry()) + .picker(test_picker()); + let logs = captured_logs(|| { + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("fill-optional-fuzzy")); + // An unfillable optional slot is a log line, not a report field. + assert!(requirements.is_satisfied()); + assert!(ctx.tool_bindings().is_empty()); + }); + assert!( + logs.contains("email"), + "the skip log line names the alias: {logs}" + ); +} diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index f84c518c..72870bc5 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -976,7 +976,7 @@ Prepare assembles contributed tools into the run's `ToolCatalog` in declaration -### Step 12: ToolBindings and slot filling +### Step 12: ToolBindings and slot filling [completed] - Component: binding From 0ac3e277d5ecf79deb5cc9926299990b8bba1664 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 13 Sep 2026 23:40:22 -0700 Subject: [PATCH 13/30] Bind from the frontmatter and run H1 as section 0 The prompt's tool slots and model roles now arrive bound from the frontmatter at prepare, so the live binding pass that resolved them during the first section, its picker resolver machinery, and its whole error family are gone. That first section now runs through the same install path as every other: the control globals work there, and a failed assertion ends the run before the walk as an unmet-requirements failure carrying the assertion's notice. The run's tool and model sets are shared with every section rather than snapshotted, with the prompt-wide records as the only writers. Model handles expose the role's label and full keyword set, and the duplicated alias grammar helper is consolidated into one module. - `bound_tool_set` builds the run's tool set from prepare's filled slots at context construction, and `bound_model_set` does the same for roles, freezing each role's hard-keyword thinking switch and keyword set onto the binding; run-time execution never consults the assembled catalog again. - `SectionVm` shares the run's tool and model sets through `Arc` handles instead of owned snapshots, so a prompt-wide record lands where every later section sees it. - `install_models` and `install_tools` are the one install path for every section, the first section included; the phase-split tables and their only-available-during stubs are gone. - `validate_alias` consolidates the two near-identical alias grammar helpers into one module shared by the tools and models tables. - `ModelBinding` carries the bound role's full keyword set, exposed on the Lua handle as `label` and `capabilities`. - `Error::RequirementsUnmet` is the remap a failed first-section assertion gets: the chunk's own Lua failure ends the run before the walk with the assertion's message as the notice, while failures of the machinery around the chunk keep their own kinds. - `jump` out of the first section ends the pass and starts the walk at the resolved top-level target, and `call`, `fanout`, and `list_from_section` resolve against the whole top-level slice from there. - `models.default` takes a label, is idempotent for the same label under the shared-library replay, and refuses a different label mid-run. - `tools.add` and `tools.always` see only filled slots: advertising or calling an unfilled alias fails at run time as not a bound tool slot, exactly as prepare's report promised. - `record_near_duplicate_conflicts` runs the conflict scan at prepare's slot fill, recording near-duplicate pairs symmetrically on the journaled bindings; an unanalyzable set logs and records nothing. - `tools.bind` and `models.bind` are removed from the Lua surface, calling one is a nil call, and with them go the live binding producer, the single-flight picker resolver cache, and the bind-time error family: `crates/promptforge-api/src/resolve.rs`, `crates/promptforge-lua/src/live.rs`, and `crates/promptforge-lua/src/models/decode.rs` are deleted. - `Environment` sheds its interim model and tool catalogs and their builders; the picker remains only behind prepare's fuzzy slot fills. Design: clone-block -> pure-function @ crates/promptforge-lua/src/alias.rs::validate_alias deps: &str Design: new pure-function @ crates/promptforge-api/src/execute/context.rs::bound_tool_set deps: Prompt,RunContext Design: new pure-function @ crates/promptforge-api/src/execute/context.rs::bound_model_set deps: Prompt,RunContext Design: new shared-mutable-state @ crates/promptforge-lua/src/vm.rs::SectionVm Design: new surface-growth @ crates/promptforge-lua/src/models/userdata.rs::LuaModelHandle boundary: pub Design: removes temporal-coupling @ crates/promptforge-lua/src/live.rs Design: removes temporal-coupling @ crates/promptforge-lua/src/vm.rs::SectionVm::install_h1_control_stubs Design: removes temporal-coupling @ crates/promptforge-lua/src/models/mod.rs::install_h2_models Design: removes temporal-coupling @ crates/promptforge-lua/src/tools/mod.rs::install_h2_tools Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/benches/models_loop.rs | 18 +- crates/promptforge-api/src/error.rs | 146 +--- crates/promptforge-api/src/execute.rs | 34 +- .../promptforge-api/src/execute/bindings.rs | 79 +- crates/promptforge-api/src/execute/config.rs | 32 +- crates/promptforge-api/src/execute/context.rs | 167 +++-- .../src/execute/environment.rs | 83 +-- crates/promptforge-api/src/execute/error.rs | 12 +- crates/promptforge-api/src/execute/fill.rs | 40 + crates/promptforge-api/src/execute/gateway.rs | 48 +- .../promptforge-api/src/execute/scheduler.rs | 357 ++++----- .../src/execute/section_context.rs | 75 +- .../src/execute/tests/debug_and_counts.rs | 27 +- .../src/execute/tests/exec_flow.rs | 104 ++- .../src/execute/tests/live_infer.rs | 154 +--- .../src/execute/tests/local_tools.rs | 11 +- .../promptforge-api/src/execute/tests/mod.rs | 195 +++-- .../src/execute/tests/model_and_reply.rs | 245 +++--- .../src/execute/tests/observations.rs | 54 +- .../src/execute/tests/scheduler.rs | 390 ++++++---- .../src/execute/tests/tool_scoping.rs | 6 +- .../src/execute/tests/unified_pipeline.rs | 5 +- crates/promptforge-api/src/lib.rs | 1 - crates/promptforge-api/src/lua.rs | 16 +- crates/promptforge-api/src/lua/coro_tests.rs | 6 +- crates/promptforge-api/src/model.rs | 4 +- .../promptforge-api/src/model/tests/always.rs | 340 +++------ .../src/model/tests/integration.rs | 167 ++--- crates/promptforge-api/src/model/tests/mod.rs | 120 +-- crates/promptforge-api/src/resolve.rs | 702 ------------------ crates/promptforge-api/tests/suite/prepare.rs | 81 +- crates/promptforge-api/tests/suite/support.rs | 15 +- crates/promptforge-lua/benches/surface.rs | 5 +- crates/promptforge-lua/src/alias.rs | 74 ++ crates/promptforge-lua/src/coro.rs | 72 +- crates/promptforge-lua/src/error.rs | 97 --- crates/promptforge-lua/src/handles.rs | 80 +- crates/promptforge-lua/src/lib.rs | 21 +- crates/promptforge-lua/src/live.rs | 338 --------- crates/promptforge-lua/src/models/decode.rs | 222 ------ crates/promptforge-lua/src/models/mod.rs | 326 +++----- crates/promptforge-lua/src/models/tests.rs | 397 +++------- crates/promptforge-lua/src/models/userdata.rs | 34 +- crates/promptforge-lua/src/tests.rs | 527 ++++++------- crates/promptforge-lua/src/tools/mod.rs | 113 ++- crates/promptforge-lua/src/tools/tests.rs | 45 +- crates/promptforge-lua/src/tools/userdata.rs | 29 +- crates/promptforge-lua/src/vm.rs | 176 +++-- .../src/model/options.rs | 18 + crates/promptforge-parser/src/contract.rs | 5 +- .../src/capabilities.rs | 2 +- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 52 files changed, 2176 insertions(+), 4141 deletions(-) delete mode 100644 crates/promptforge-api/src/resolve.rs create mode 100644 crates/promptforge-lua/src/alias.rs delete mode 100644 crates/promptforge-lua/src/live.rs delete mode 100644 crates/promptforge-lua/src/models/decode.rs diff --git a/crates/promptforge-api/benches/models_loop.rs b/crates/promptforge-api/benches/models_loop.rs index 5a26b34c..697a4791 100644 --- a/crates/promptforge-api/benches/models_loop.rs +++ b/crates/promptforge-api/benches/models_loop.rs @@ -30,7 +30,6 @@ use promptforge_api::{Environment, Prompt, RunContext, RunResult}; 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"; @@ -122,10 +121,10 @@ fn bench_catalog(context: u32) -> ModelCatalog { } /// One section driving `models.loop` over a builder-made list. -const LOOP_PROMPT: &str = "---\nname: bench_loop\ndescription: d\npromptforge: 0\n---\n\n\ +const LOOP_PROMPT: &str = "---\nname: bench_loop\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Bench\n\n\ ```lua\n\ - models.default('writer', 'A general model for benches')\n\ + models.default('writer')\n\ ```\n\n\ ## Only\n\n\ ```lua\n\ @@ -143,11 +142,8 @@ fn parse_loop_prompt() -> Prompt { /// The environment every bench run shares: an empty tool picker and no /// tools, so the loop is one terminal turn. -fn bench_env(picker: ToolPicker, models: ModelCatalog) -> Environment { - Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::new(&[]).expect("the empty bench tool catalog builds")) +fn bench_env(picker: ToolPicker) -> Environment { + Environment::new().picker(picker) } /// One `models.loop` turn end to end: parse is excluded, so the measurement @@ -163,7 +159,7 @@ fn models_loop(c: &mut Criterion) { let prompt = parse_loop_prompt(); let picker = ToolPicker::build(Catalog::new(Vec::new()), Config::default()) .expect("the empty bench picker builds"); - let env = bench_env(picker, bench_catalog(131_072)); + let env = bench_env(picker); c.bench_function("models_loop", |b| { b.iter(|| { let result = runtime.block_on( @@ -172,6 +168,7 @@ fn models_loop(c: &mut Criterion) { "", RunContext::new(EXECUTION) .observer(Arc::new(NullObserver::default())) + .model(bench_catalog(131_072).models()[0].clone()) .client(gateway.client()), ), ); @@ -200,7 +197,7 @@ fn compactors_fail(c: &mut Criterion) { let prompt = parse_loop_prompt(); let picker = ToolPicker::build(Catalog::new(Vec::new()), Config::default()) .expect("the empty bench picker builds"); - let env = bench_env(picker, bench_catalog(1)); + let env = bench_env(picker); c.bench_function("compactors_fail", |b| { b.iter(|| { let result = runtime.block_on( @@ -209,6 +206,7 @@ fn compactors_fail(c: &mut Criterion) { "", RunContext::new(EXECUTION) .observer(Arc::new(NullObserver::default())) + .model(bench_catalog(1).models()[0].clone()) .client(gateway.client()), ), ); diff --git a/crates/promptforge-api/src/error.rs b/crates/promptforge-api/src/error.rs index 419f7946..3fee934a 100644 --- a/crates/promptforge-api/src/error.rs +++ b/crates/promptforge-api/src/error.rs @@ -239,16 +239,6 @@ pub(crate) enum Error { source: BoxedSource, }, - /// The concrete picker failed while resolving a capability declaration. - #[error("tool capability binding failure for {capability:?}: {detail}")] - #[non_exhaustive] - Bind { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The picker failure without exposing its concrete error type. - detail: String, - }, - /// Building a model-facing tool schema for a bound alias failed, retaining /// the schema validation error as the private `#[source]` cause (F5) rather /// than flattening it into `detail`. @@ -265,94 +255,6 @@ pub(crate) enum Error { source: BoxedSource, }, - /// The picker's query failed while resolving a capability, retaining the - /// picker's own typed error as the private `#[source]` cause (resolve F4) - /// so the failure chain survives the resolution cache instead of being - /// flattened to a string. - #[error("tool capability binding failure for {capability:?}: {source}")] - #[non_exhaustive] - BindQuery { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The picker's typed query failure, kept as a shareable cause. - #[source] - source: SharedSource, - }, - - /// No picker catalog entry matched a declared capability. - #[error("no tool matches capability {capability:?}")] - #[non_exhaustive] - Absent { - /// The exact capability description passed to `tools.bind`. - capability: String, - }, - - /// One server published duplicate matches for a declared capability. - #[error("duplicate tools match capability {capability:?}: {candidates:?}")] - #[non_exhaustive] - Duplicate { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The stable identities reported by the picker, in picker order. - candidates: Vec, - }, - - /// The picker could not choose uniquely among capability matches. - #[error("ambiguous tools match capability {capability:?}: {candidates:?}")] - #[non_exhaustive] - Ambiguous { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The stable identities reported by the picker, in picker order. - candidates: Vec, - }, - - /// One prompt-local alias was declared more than once. - #[error("tool alias {alias:?} was declared more than once")] - #[non_exhaustive] - DuplicateAlias { - /// The exact case-sensitive alias declared by the prompt. - alias: String, - }, - - /// Two prompt-local aliases selected the same stable tool identity. - #[error( - "tool identity {id:?} was selected by both aliases {first_alias:?} and {second_alias:?}" - )] - #[non_exhaustive] - ToolIdSelectedTwice { - /// The stable identity selected more than once. - id: crate::tools::ToolId, - /// The first alias in declaration order. - first_alias: String, - /// The later conflicting alias. - second_alias: String, - }, - - /// A picker-selected stable identity is not callable in the live tool - /// catalog. - #[error( - "alias {alias:?} selected tool identity {id:?}, which is absent from the live tool catalog" - )] - #[non_exhaustive] - PickedToolNotLive { - /// The prompt-local alias whose selection cannot be fulfilled. - alias: String, - /// The selected stable identity absent from the catalog. - id: crate::tools::ToolId, - }, - - /// The picker's near-duplicate analysis of the selected tool scope failed, - /// retaining the picker's typed selection error as the private `#[source]` - /// cause (F5) rather than flattening it into a string. - #[error("selected tool-scope analysis failure")] - #[non_exhaustive] - ToolScopeAnalysisSource { - /// The picker's typed selection failure, kept as the cause. - #[source] - source: BoxedSource, - }, - /// Two tools in one model-visible scope are semantic near-duplicates. #[error( "tool aliases {first_alias:?} ({first_id:?}) and {second_alias:?} ({second_id:?}) are near-duplicates with similarity {similarity}", @@ -421,14 +323,6 @@ pub(crate) enum Error { candidates: Vec, }, - /// One prompt-local model alias was declared more than once. - #[error("model alias {alias:?} was declared more than once")] - #[non_exhaustive] - DuplicateModelAlias { - /// The exact case-sensitive alias declared by the prompt. - alias: String, - }, - /// A `{{ }}` prose substitution failed (unknown/missing path, unclosed). /// /// Carries a typed [`crate::subst::SubstitutionError`] with a stable kind, @@ -446,7 +340,7 @@ pub(crate) enum Error { /// This is the model tool loop's error alone: a script `tools.call` /// resolves against the run's full bound catalog and fails with /// [`Error::UnboundToolCall`] instead. - #[error("tool {name:?} is not in this section's scope; in-scope aliases: {in_scope:?}{}", if *.global_exists { " (alias was declared by tools.bind but not added to this section's scope)" } else { "" })] + #[error("tool {name:?} is not in this section's scope; in-scope aliases: {in_scope:?}{}", if *.global_exists { " (alias is a bound tool slot but was not added to this section's scope)" } else { "" })] #[non_exhaustive] OutOfScopeToolCall { /// The alias or identifier the model tried to use. @@ -631,6 +525,7 @@ impl Error { /// Wrap an `mlua` failure as [`Error::LuaRuntime`], preserving it as the /// `#[source]` cause (F4) rather than flattening it to a string. + #[cfg(test)] pub(crate) fn lua(source: mlua::Error) -> Error { Error::LuaRuntime { message: source.to_string(), @@ -782,38 +677,6 @@ impl From for Error { LuaError::Interrupted => Error::Interrupted, LuaError::Tool { message, source } => Error::Tool { message, source }, LuaError::Internal(message) => Error::internal(message), - LuaError::DuplicateAlias { alias } => Error::DuplicateAlias { alias }, - LuaError::PickedToolNotLive { alias, id } => Error::PickedToolNotLive { alias, id }, - LuaError::ToolIdSelectedTwice { - id, - first_alias, - second_alias, - } => Error::ToolIdSelectedTwice { - id, - first_alias, - second_alias, - }, - LuaError::Bind { capability, detail } => Error::Bind { capability, detail }, - LuaError::BindQuery { capability, source } => Error::BindQuery { capability, source }, - LuaError::Absent { capability } => Error::Absent { capability }, - LuaError::Duplicate { - capability, - candidates, - } => Error::Duplicate { - capability, - candidates, - }, - LuaError::Ambiguous { - capability, - candidates, - } => Error::Ambiguous { - capability, - candidates, - }, - LuaError::ToolScopeAnalysisSource { source } => { - Error::ToolScopeAnalysisSource { source } - } - LuaError::DuplicateModelAlias { alias } => Error::DuplicateModelAlias { alias }, LuaError::ModelBind { capability, detail } => Error::ModelBind { capability, detail }, LuaError::ModelBindQuery { capability, source } => { Error::ModelBindQuery { capability, source } @@ -907,11 +770,6 @@ mod tests { "model-facing schema build failure for tool alias \"echo\"" ); assert_source_survives_run_error(bind); - - let analysis = Error::ToolScopeAnalysisSource { - source: Box::new(std::io::Error::other("picker selection failed")), - }; - assert_source_survives_run_error(analysis); } #[test] diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index 4b5da0d0..0956e78a 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -29,8 +29,9 @@ //! 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. -//! Prompt-wide aliases and H2 additions form the effective model-visible scope, +//! Rust installs the run's filled tool and model slots - bound at prepare +//! from the frontmatter - into each section VM. Prompt-wide aliases and +//! section additions form the effective model-visible scope, //! which is checked for semantic near-duplicates before concrete tools are //! advertised under their local aliases and dispatched through the //! implementation each binding carries. @@ -98,7 +99,6 @@ pub use bindings::{ModelBindings, ToolBindings}; pub use config::{RunContext, RunLimits}; pub use environment::Environment; pub use error::{RunError, RunErrorKind, SourceLocation}; -pub(crate) use gateway::ResolutionContext; pub use requirements::{CapabilityConflict, RequirementCheck, Requirements, UnmetRequirement}; use context::RunState; @@ -127,14 +127,15 @@ pub enum RunResult { /// Executes a parsed prompt and returns its final text. /// -/// H1 Lua and prose blocks run once in source order with full host access; -/// capability calls resolve when executed. If H1 does not return, the H2 section -/// walk runs and its final text is returned. +/// H1 is section 0: its Lua and prose blocks run once in source order with +/// the same surface every section gets (its only privilege, `argv` +/// writability, arrives with the args/argv step). If H1 does not return, +/// the H2 section walk runs and its final text is returned. /// /// The free `run` receives an already-prepared [`RunContext`] and has /// nothing to prepare from: a context that never passed through -/// [`Environment::prepare`] runs capability-free (no picker, empty -/// catalogs). Hosts normally go through [`Environment::run`], the +/// [`Environment::prepare`] runs capability-free (empty tool and model +/// sets). Hosts normally go through [`Environment::run`], the /// zero-burden path. /// /// # Outcomes @@ -256,31 +257,16 @@ pub async fn run(prompt: &Prompt, args: &str, ctx: RunContext) -> RunResult { client, cancel, limits, - resolution, .. } = ctx; let client = client.map(|client| client.with_request_limits(limits.timeout(), limits.response_bytes())); observer.observe(&name, prompt.title(), detail::RUN_STARTED); - // A context that never passed through `Environment::run` carries no - // resolution inputs and runs capability-free: no picker, empty catalogs. - let resolution = resolution.unwrap_or_default(); - let live = ResolutionContext::new( - resolution.picker.as_deref(), - &resolution.models, - &resolution.tools, - ); - // Boxed: the driver future carries the whole scheduler step machinery, // and `run`'s own future must stay small for its callers (the // workspace's large-futures lint gates every one of them). - let run_body = Box::pin(async { - Scheduler::new(&state, client) - .with_live_h1(live) - .drive() - .await - }); + let run_body = Box::pin(async { Scheduler::new(&state, client).drive().await }); // Explicit cancellation: when the caller supplies a handle it is installed // for the run so cooperative cancel checks observe it; without one the run diff --git a/crates/promptforge-api/src/execute/bindings.rs b/crates/promptforge-api/src/execute/bindings.rs index ec94f79b..d4c051e2 100644 --- a/crates/promptforge-api/src/execute/bindings.rs +++ b/crates/promptforge-api/src/execute/bindings.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::fmt; use std::sync::Arc; +use promptforge_lua::Conflict; use shared_promptforge_api::tools::Tool; use crate::model::{ModelDescriptor, ModelId}; @@ -84,6 +85,11 @@ pub struct ToolBindings { aliases: BTreeMap, /// What this run may dispatch: identity to tool. tools: BTreeMap>, + /// Near-duplicate clashes per alias, recorded by the fill's conflict + /// scan: the alias's bound tool is a near-verbatim copy of the named + /// sibling alias's tool. Binding records, never fails: a clash errors + /// only when both halves enter one model-visible scope. + conflicts: BTreeMap>, } impl ToolBindings { @@ -113,6 +119,53 @@ impl ToolBindings { self.tools.get(id) } + /// The distinct identities the fill bound, for the conflict scan. + pub(crate) fn bound_ids(&self) -> Vec { + self.tools.keys().cloned().collect() + } + + /// Records one near-duplicate pair symmetrically: every alias bound + /// to `first` clashes with every alias bound to `second`, and back. + /// The score is the picker's cosine similarity, widened once at the + /// scan. + pub(crate) fn record_conflict(&mut self, first: &ToolId, second: &ToolId, similarity: f64) { + let aliases_of = |id: &ToolId| -> Vec { + self.aliases + .iter() + .filter(|(_, bound)| *bound == id) + .map(|(alias, _)| alias.clone()) + .collect() + }; + let firsts = aliases_of(first); + let seconds = aliases_of(second); + for first_alias in &firsts { + for second_alias in &seconds { + self.conflicts + .entry(first_alias.clone()) + .or_default() + .push(Conflict { + alias: second_alias.clone(), + similarity, + }); + self.conflicts + .entry(second_alias.clone()) + .or_default() + .push(Conflict { + alias: first_alias.clone(), + similarity, + }); + } + } + } + + /// The near-duplicate clashes recorded against `alias` at the fill, + /// empty when the conflict scan found none. Journaled with the + /// bindings so hosts and evals see what the scan recorded. + #[must_use] + pub fn conflicts(&self, alias: &str) -> &[Conflict] { + self.conflicts.get(alias).map_or(&[], Vec::as_slice) + } + /// Returns the number of bound aliases. #[must_use] pub fn len(&self) -> usize { @@ -129,10 +182,12 @@ impl ToolBindings { impl fmt::Debug for ToolBindings { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // The tools are trait objects; their identities stand in, and - // the journaled decision (alias to identity) is the content. + // the journaled decision (alias to identity, recorded clashes) + // is the content. f.debug_struct("ToolBindings") .field("aliases", &self.aliases) .field("tools", &self.tools.keys().collect::>()) + .field("conflicts", &self.conflicts) .finish() } } @@ -258,4 +313,26 @@ mod tests { .is_none() ); } + + #[test] + fn a_recorded_conflict_lands_on_both_aliases_symmetrically() { + // The fill's conflict scan records a near-duplicate pair on every + // alias bound to each half, so the scope check fires whichever + // alias pair enters one model-visible scope. + let first = ToolId::parse("promptforge/web/fetch").expect("the test id is valid"); + let second = ToolId::parse("promptforge/web/getter").expect("the test id is valid"); + let mut bindings = ToolBindings::default(); + bindings.bind("fetch", Arc::new(FixtureTool { id: first.clone() })); + bindings.bind("getter", Arc::new(FixtureTool { id: second.clone() })); + assert!(bindings.conflicts("fetch").is_empty()); + bindings.record_conflict(&first, &second, 0.97); + let fetch_conflicts = bindings.conflicts("fetch"); + assert_eq!(fetch_conflicts.len(), 1); + assert_eq!(fetch_conflicts[0].alias, "getter"); + assert!((fetch_conflicts[0].similarity - 0.97).abs() < f64::EPSILON); + let getter_conflicts = bindings.conflicts("getter"); + assert_eq!(getter_conflicts.len(), 1); + assert_eq!(getter_conflicts[0].alias, "fetch"); + assert!(bindings.conflicts("unbound").is_empty()); + } } diff --git a/crates/promptforge-api/src/execute/config.rs b/crates/promptforge-api/src/execute/config.rs index 48a13809..15682cb5 100644 --- a/crates/promptforge-api/src/execute/config.rs +++ b/crates/promptforge-api/src/execute/config.rs @@ -5,13 +5,11 @@ use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize}; use std::sync::Arc; use std::time::{Duration, SystemTime}; -use promptforge_tool_picker::ToolPicker; - use crate::cancel::CancelHandle; use crate::client::{GatewayClient, StreamDelta}; use crate::debug::DebugCapture; use crate::input::InputBroker; -use crate::model::{ModelCatalog, ModelDescriptor}; +use crate::model::ModelDescriptor; use crate::observe::{NullObserver, Observer}; use crate::store::VfsRef; use crate::tools::ToolCatalog; @@ -174,23 +172,6 @@ impl Default for RunLimits { } } -/// The live resolution inputs -/// [`Environment::prepare`](super::Environment::prepare) installs on a -/// context before the free [`run`](super::run) drives it: the interim -/// stand-in for the catalog-assembly step, absorbing what the retired -/// borrowed resolution context carried (a picker, a model catalog, a tool -/// catalog). A context without one runs capability-free. -#[derive(Clone, Default)] -pub(crate) struct RunResolution { - /// Semantic picker behind executed H1 binds; `None` fails a bind as a - /// binding error naming the missing picker. - pub(crate) picker: Option>, - /// Live model catalog behind executed H1 model calls. - pub(crate) models: ModelCatalog, - /// Tool catalog behind executed H1 `tools.bind` calls. - pub(crate) tools: ToolCatalog, -} - /// One run. Created by the host from the /// [`Environment`](super::Environment) carrying the per-run inputs, /// enriched at prepare, owned by the executor during @@ -246,11 +227,6 @@ pub struct RunContext { /// fill against the assembled catalog: which concrete tool each /// declared alias is bound to, with every fuzzy fill journaled. pub(crate) tool_bindings: ToolBindings, - /// The resolution inputs - /// [`Environment::prepare`](super::Environment::prepare) installs; - /// `None` on a caller-built context, which the free - /// [`run`](super::run) treats as capability-free. - pub(crate) resolution: Option, } impl RunContext { @@ -277,7 +253,6 @@ impl RunContext { model_bindings: ModelBindings::default(), tools: ToolCatalog::default(), tool_bindings: ToolBindings::default(), - resolution: None, } } @@ -356,8 +331,8 @@ impl RunContext { /// [`Environment::prepare`](super::Environment::prepare)'s fill /// function, which binds every declared role to it and checks the /// roles' hard keywords and context minimums against its descriptor. - /// The default (`None`) fills nothing: the interim Lua-side catalog - /// resolution carries the run. + /// The default (`None`) fills nothing: declared roles stay unbound and + /// selecting one at run time fails. #[must_use] pub fn model(mut self, model: ModelDescriptor) -> RunContext { self.model = Some(model); @@ -469,7 +444,6 @@ impl fmt::Debug for RunContext { .field("model_bindings", &self.model_bindings) .field("tools", &self.tools) .field("tool_bindings", &self.tool_bindings) - .field("resolution", &self.resolution.is_some()) .finish() } } diff --git a/crates/promptforge-api/src/execute/context.rs b/crates/promptforge-api/src/execute/context.rs index 2fb8140f..eeda4995 100644 --- a/crates/promptforge-api/src/execute/context.rs +++ b/crates/promptforge-api/src/execute/context.rs @@ -10,11 +10,13 @@ use std::fmt; use std::sync::atomic::{AtomicU32, AtomicU64}; use std::sync::{Arc, Mutex}; +use promptforge_parser::{ModelKeyword, ToolSlot}; + use crate::Result; use crate::debug::DebugCapture; use crate::input::InputBroker; -use crate::lua::{LuaProgram, ToolSet, ToolView}; -use crate::model::{ModelSet, ModelView}; +use crate::lua::{LuaProgram, ToolBinding, ToolSet, ToolView}; +use crate::model::{ModelBinding, ModelInvocation, ModelSet, ModelView}; use crate::observe::Observer; use crate::parser::Prompt; use crate::store::{Access, VfsRef}; @@ -24,6 +26,96 @@ use super::config::{RunContext, RunLimits}; use super::section_vm::{SectionVmSetup, VmSeed}; use super::support::{now_rfc3339_checked, sys_json}; +/// Builds the run's shared tool set from the prepared bindings: every +/// filled slot becomes a binding carrying its resolved implementation, so +/// run-time execution never consults the assembled catalog again. Unfilled +/// slots produce no binding: advertising or calling the alias fails at run +/// time, exactly as prepare's report promised. +fn bound_tool_set(prompt: &Prompt, ctx: &RunContext) -> ToolSet { + let mut set = ToolSet::default(); + for (alias, slot) in prompt.frontmatter().tools().iter() { + let Some(tool) = ctx.tool_bindings.resolve(alias) else { + continue; + }; + let description = match slot { + ToolSlot::Fuzzy(fuzzy) => fuzzy.want().to_owned(), + // The exact path says nothing prose-like; the tool's own + // catalog text stands in. + _ => tool.description().to_owned(), + }; + set.bindings.push(ToolBinding { + alias: alias.to_owned(), + description, + id: tool.id(), + model_description: None, + tool: Arc::clone(tool), + conflicts: ctx.tool_bindings.conflicts(alias).to_vec(), + output_kind: crate::lua::ToolOutputKind::Plain, + }); + } + set +} + +/// The keyword's stable kebab-case spelling, journaled onto the binding as +/// the role's capability set. +fn keyword_name(keyword: ModelKeyword) -> &'static str { + match keyword { + ModelKeyword::Thinking => "thinking", + ModelKeyword::NoThinking => "no-thinking", + ModelKeyword::Frontier => "frontier", + ModelKeyword::Fast => "fast", + ModelKeyword::Small => "small", + ModelKeyword::Creative => "creative", + ModelKeyword::Chat => "chat", + // The vocabulary is closed today; a future keyword reports its + // debug spelling rather than breaking the fill. + _ => "unknown", + } +} + +/// Builds the run's shared model set from the prepared bindings: every +/// filled role becomes a binding under its label, carrying the role's +/// keyword set (the handle's `capabilities`) and the hard-keyword thinking +/// switch as the frozen invocation. Unfilled roles produce no binding: +/// `models.use` on the label fails at run time. +fn bound_model_set(prompt: &Prompt, ctx: &RunContext) -> ModelSet { + let mut set = ModelSet::default(); + for (label, role) in prompt.frontmatter().models().iter() { + let Some(descriptor) = ctx.model_bindings.resolve(label) else { + continue; + }; + let mut thinking = None; + for keyword in role.keywords() { + match keyword { + ModelKeyword::Thinking => thinking = Some(true), + ModelKeyword::NoThinking => thinking = Some(false), + _ => {} + } + } + let binding = ModelBinding::new( + label, + role.description() + .unwrap_or_else(|| descriptor.description()), + descriptor.id().clone(), + ModelInvocation { + temperature: None, + max_tokens: None, + thinking, + }, + descriptor.context(), + ) + .with_capabilities( + role.keywords() + .iter() + .map(|keyword| keyword_name(*keyword)) + .map(str::to_owned) + .collect(), + ); + set.bindings.push(binding); + } + set +} + /// The ambient state one run shares across the execute subtree. /// /// Immutable for the run's lifetime and cheap to clone: every field is @@ -65,23 +157,21 @@ pub(crate) struct RunState { /// compiled chunk when the prompt declares no `lua shared` library, so /// the startup sequence carries no `Option` branch. shared: Arc, - /// The run's tool set as a read-only view: created empty here and - /// filled by the live H1 pass through the concrete `tool_set` handle. - /// The trait exposes no write methods, so once the H1 VM drops its - /// handle clones the set is structurally frozen. + /// The run's tool set as a read-only view: built from the prepared + /// bindings at construction. The trait exposes no write methods; the + /// only writer is `tools.always` (a prompt-wide fact) through the + /// concrete handle the section VMs share. tools: Arc, - /// The concrete handle behind `tools`, handed to the live H1 binding - /// producer (its Lua host closures write through it). Readers never - /// touch it; they go through the view. + /// The concrete handle behind `tools`, shared with every section VM + /// (H1 included). Readers outside the VM layer go through the view. tool_set: Arc>, - /// The run's model set as a read-only view: created empty here and - /// filled by the live H1 pass through the concrete `model_set` handle. - /// The trait exposes no write methods, so once the H1 VM drops its - /// handle clones the set is structurally frozen. + /// The run's model set as a read-only view: built from the prepared + /// bindings at construction. The trait exposes no write methods; the + /// only writer is `models.default` (a prompt-wide fact) through the + /// concrete handle the section VMs share. models: Arc, - /// The concrete handle behind `models`, handed to the live H1 binding - /// producer (its Lua host closures write through it). Readers never - /// touch it; they go through the view. + /// The concrete handle behind `models`, shared with every section VM + /// (H1 included). Readers outside the VM layer go through the view. model_set: Arc>, /// The walk's start timestamp, stamped into every section's `sys.when`; /// empty until the walk starts (H1 stamps its own `now`). @@ -99,9 +189,12 @@ pub(crate) struct RunState { impl RunState { /// Builds the context for one run of `prompt`. The turn and id counters - /// are minted here (both start at zero), as are the empty tool and model - /// sets the live H1 pass fills through the concrete handles; `when` - /// starts empty and takes its live value at the H1-to-walk handoff. + /// are minted here (both start at zero), as are the run's shared tool + /// and model sets - built from the prepared bindings on `ctx` (empty on + /// a caller-built context that never passed through + /// [`Environment::prepare`](super::Environment::prepare), which runs + /// capability-free); `when` starts empty and takes its live value at the + /// H1-to-walk handoff. #[must_use] pub(crate) fn new( prompt: &Prompt, @@ -110,8 +203,8 @@ impl RunState { shared: LuaProgram, ctx: &RunContext, ) -> Self { - let tool_set = Arc::new(Mutex::new(ToolSet::default())); - let model_set = Arc::new(Mutex::new(ModelSet::default())); + let tool_set = Arc::new(Mutex::new(bound_tool_set(prompt, ctx))); + let model_set = Arc::new(Mutex::new(bound_model_set(prompt, ctx))); Self { prompt: Arc::new(prompt.clone()), nonce: GuardNonce::fresh(), @@ -196,16 +289,14 @@ impl RunState { &*self.tools } - /// The concrete handle behind the tools view, for the live H1 binding - /// producer; its clones die with the H1 VM, after which the set is - /// structurally frozen. + /// The concrete handle behind the tools view, shared with every + /// section VM the run constructs. pub(crate) fn tool_set(&self) -> Arc> { Arc::clone(&self.tool_set) } /// An owned snapshot of the run's tool set (bindings plus `always`), - /// read through the view. Post-H1 the set is frozen, so the two reads - /// always agree. + /// read through the view. /// /// # Errors /// Returns [`Error::Lua`](crate::Error::Lua) if the set's mutex is @@ -222,27 +313,12 @@ impl RunState { &*self.models } - /// The concrete handle behind the models view, for the live H1 binding - /// producer; its clones die with the H1 VM, after which the set is - /// structurally frozen. + /// The concrete handle behind the models view, shared with every + /// section VM the run constructs. pub(crate) fn model_set(&self) -> Arc> { Arc::clone(&self.model_set) } - /// An owned snapshot of the run's model set (bindings plus `default`), - /// read through the view. Post-H1 the set is frozen, so the two reads - /// always agree. - /// - /// # Errors - /// Returns [`Error::Lua`](crate::Error::Lua) if the set's mutex is - /// poisoned. - pub(crate) fn model_set_snapshot(&self) -> Result { - Ok(ModelSet::from_parts( - self.models.bindings()?, - self.models.default()?, - )) - } - /// The resolved per-section tool-loop cap: the frontmatter's /// `max_tool_iterations` over the limits default. pub(crate) fn max_tool_iterations(&self) -> usize { @@ -271,8 +347,9 @@ impl RunState { /// The H1-to-walk handoff: the walk's start timestamp, set on a cheap /// clone so the context H1 saw stays untouched. The tool and model sets - /// need no delta: H1's binds already landed in the shared sets the views - /// read. + /// need no delta: they were built from the prepared bindings at + /// construction, and H1's prompt-wide records (`tools.always`, + /// `models.default`) landed in the same shared sets the views read. #[must_use] pub(crate) fn with_walk_state(&self, when: &str) -> Self { let mut ctx = self.clone(); diff --git a/crates/promptforge-api/src/execute/environment.rs b/crates/promptforge-api/src/execute/environment.rs index 0961596f..96bc3e35 100644 --- a/crates/promptforge-api/src/execute/environment.rs +++ b/crates/promptforge-api/src/execute/environment.rs @@ -8,13 +8,11 @@ use shared_promptforge_api::capabilities::{Capability, CapabilityId, Contributio use crate::capabilities::CapabilityRegistry; use crate::client::GatewayClient; -use crate::model::ModelCatalog; use crate::parser::Prompt; use crate::store::VfsRef; -use crate::tools::ToolCatalog; use super::RunResult; -use super::config::{RunContext, RunResolution}; +use super::config::RunContext; use super::fill::{assemble_catalog, fill_model_bindings, fill_tool_bindings}; use super::requirements::{CapabilityConflict, Requirements}; @@ -25,26 +23,19 @@ use super::requirements::{CapabilityConflict, Requirements}; /// change per run rides the [`RunContext`]. Model-free: the gateway's /// model list is a host-UI concern and never crosses this interface. /// -/// Interim state (the interface consolidation step): the environment -/// absorbs the retired resolution context's contents - the picker, the -/// model catalog, and the tool catalog - as internal fields, and prose -/// binding still works. [`prepare`](Environment::prepare) installs those -/// inputs on the context, resolves the prompt's declared capabilities -/// against the registry (rejecting co-activation conflicts), assembles -/// the activated contributions into the run's tool catalog, builds the -/// per-run router from `base_vfs`, and fills the model bindings from -/// the context's current model; the `max_depth` guard lands with the -/// sub-run adapter in the deferred prompt-pack work and is carried, not -/// consulted, until then. +/// [`prepare`](Environment::prepare) resolves the prompt's declared +/// capabilities against the registry (rejecting co-activation conflicts), +/// assembles the activated contributions into the run's tool catalog, +/// builds the per-run router from `base_vfs`, fills the tool slots against +/// the assembled catalog (fuzzy slots through the picker), and fills the +/// model bindings from the context's current model; the `max_depth` guard +/// lands with the sub-run adapter in the deferred prompt-pack work and is +/// carried, not consulted, until then. #[non_exhaustive] pub struct Environment { - /// Semantic picker behind executed H1 binds (interim home, absorbed - /// from the retired resolution context). + /// Semantic picker behind prepare's fuzzy tool-slot fills; `None` + /// leaves fuzzy slots unfilled. picker: Option>, - /// Live model catalog behind executed H1 model calls (interim home). - models: ModelCatalog, - /// Tool catalog behind executed H1 `tools.bind` calls (interim home). - tools: ToolCatalog, /// The deployment's gateway client; a run's own client overrides it. client: Option, /// The explicit host-built set of installed capabilities a prompt's @@ -59,14 +50,12 @@ pub struct Environment { } impl Environment { - /// Builds the default environment: no picker, empty model and tool - /// catalogs, no client, no host roots, and a nesting cap of 3. + /// Builds the default environment: no picker, no client, no registry, + /// no host roots, and a nesting cap of 3. #[must_use] pub fn new() -> Environment { Environment { picker: None, - models: ModelCatalog::default(), - tools: ToolCatalog::default(), client: None, registry: None, base_vfs: VfsRef::builder().build(), @@ -74,27 +63,14 @@ impl Environment { } } - /// Sets the semantic picker executed H1 binds resolve through. + /// Sets the semantic picker prepare's fuzzy tool-slot fills resolve + /// through. #[must_use] pub fn picker(mut self, picker: ToolPicker) -> Environment { self.picker = Some(Arc::new(picker)); self } - /// Sets the live model catalog executed H1 model calls resolve against. - #[must_use] - pub fn models(mut self, models: ModelCatalog) -> Environment { - self.models = models; - self - } - - /// Sets the tool catalog executed H1 `tools.bind` calls resolve against. - #[must_use] - pub fn tools(mut self, tools: ToolCatalog) -> Environment { - self.tools = tools; - self - } - /// Sets the deployment's gateway client; a run's own client overrides /// it, and with neither, one is built from the process environment on /// first use. @@ -133,10 +109,10 @@ impl Environment { } /// Enriches the caller-created context against the prompt's - /// declarations: installs the environment's live resolution inputs and - /// client default, builds the run's VFS, activates every declared - /// capability, and fills the model bindings - reporting what the - /// caller must still satisfy. + /// declarations: installs the environment's client default, builds the + /// run's VFS, activates every declared capability, assembles the run's + /// tool catalog, and fills the tool slots and model bindings - + /// reporting what the caller must still satisfy. /// /// The per-run VFS is a fresh router mounting the environment's /// [`base_vfs`](Environment::base_vfs) at `/` plus a fresh memory @@ -171,8 +147,8 @@ impl Environment { /// against its descriptor - reported in /// [`Requirements::unmet_requirements`] with required versus actual, /// never shopped for. Soft keywords document author intent. With no - /// current model there is nothing to fill or check, and the interim - /// Lua-side catalog resolution carries the run. + /// current model there is nothing to fill or check, and declared + /// roles stay unbound. /// /// Tool slot filling follows catalog assembly: exact slots fill by /// identity against the run's catalog - an exact path's first two @@ -188,16 +164,6 @@ impl Environment { /// unfilled (advertising an unfilled alias fails at run time). pub fn prepare(&self, prompt: &Prompt, ctx: RunContext) -> (RunContext, Requirements) { let mut ctx = ctx; - // The interim resolution inputs (picker, live catalogs) ride the - // environment until prose binding leaves the run path; prepare - // installs them so a prepared context is fully equipped whether - // the host drives the free run itself or goes through - // [`run`](Environment::run). - ctx.resolution = Some(RunResolution { - picker: self.picker.clone(), - models: self.models.clone(), - tools: self.tools.clone(), - }); if ctx.client.is_none() { ctx.client.clone_from(&self.client); } @@ -282,8 +248,7 @@ impl Environment { } } ctx.tools = assemble_catalog(&activated); - let activated_ids: Vec = - activated.iter().map(|(id, _)| id.clone()).collect(); + let activated_ids: Vec = activated.iter().map(|(id, _)| id.clone()).collect(); ctx.tool_bindings = fill_tool_bindings( prompt, &ctx.tools, @@ -324,10 +289,8 @@ impl fmt::Debug for Environment { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Environment") .field("picker", &self.picker.is_some()) - .field("models", &self.models) - .field("tools", &"") .field("client", &self.client) - .field("registry", &self.registry) + .field("registry", &self.registry.is_some()) .field("base_vfs", &self.base_vfs) .field("max_depth", &self.max_depth) .finish() diff --git a/crates/promptforge-api/src/execute/error.rs b/crates/promptforge-api/src/execute/error.rs index d89762b9..c4f90f6f 100644 --- a/crates/promptforge-api/src/execute/error.rs +++ b/crates/promptforge-api/src/execute/error.rs @@ -114,23 +114,13 @@ impl RunError { Error::Internal { .. } | Error::TimestampFormat(_) => RunErrorKind::Internal, Error::Store(_) => RunErrorKind::Store, Error::Determinism(_) => RunErrorKind::Determinism, - Error::Bind { .. } - | Error::BindSchema { .. } - | Error::BindQuery { .. } - | Error::Absent { .. } - | Error::Duplicate { .. } - | Error::Ambiguous { .. } - | Error::DuplicateAlias { .. } - | Error::ToolIdSelectedTwice { .. } - | Error::PickedToolNotLive { .. } - | Error::ToolScopeAnalysisSource { .. } + Error::BindSchema { .. } | Error::NearDuplicateTools { .. } | Error::ModelBind { .. } | Error::ModelBindQuery { .. } | Error::ModelAbsent { .. } | Error::ModelDuplicate { .. } | Error::ModelAmbiguous { .. } - | Error::DuplicateModelAlias { .. } | Error::ModelRequired { .. } => RunErrorKind::Binding, } } diff --git a/crates/promptforge-api/src/execute/fill.rs b/crates/promptforge-api/src/execute/fill.rs index 52417645..f35eaa11 100644 --- a/crates/promptforge-api/src/execute/fill.rs +++ b/crates/promptforge-api/src/execute/fill.rs @@ -91,6 +91,11 @@ pub(super) fn assemble_catalog(activated: &[(CapabilityId, Contribution)]) -> To /// fuzzy slot skips with a log line; an unfillable required fuzzy slot /// is warned and left unfilled, and advertising the unfilled alias fails /// at run time. +/// +/// After the fills, the bind-time conflict scan records near-duplicate +/// pairs among the filled tools symmetrically on the bindings, so the +/// scope check errors when both halves of a clash enter one +/// model-visible scope. Binding records, never fails. pub(super) fn fill_tool_bindings( prompt: &Prompt, catalog: &ToolCatalog, @@ -177,9 +182,44 @@ pub(super) fn fill_tool_bindings( } } } + record_near_duplicate_conflicts(&mut bindings, run_picker.as_ref().or(picker)); bindings } +/// The bind-time conflict scan: near-duplicate pairs among the filled +/// tools, recorded symmetrically on the bindings so the scope check +/// errors when both halves of a clash enter one model-visible scope. +/// The scan prefers the run's fuzzy-fill picker - indexed over exactly +/// the run's catalog - and falls back to the environment picker's stored +/// vectors when no fuzzy slot needed the re-index; a similarity is a +/// property of the tools' descriptions, so both indexes agree on a pair +/// they both carry. Binding records, never fails: an unanalyzable set +/// (no picker, or a filled tool the picker never indexed) logs and +/// records nothing. +fn record_near_duplicate_conflicts(bindings: &mut ToolBindings, picker: Option<&ToolPicker>) { + let ids = bindings.bound_ids(); + if ids.len() < 2 { + return; + } + let Some(picker) = picker else { + return; + }; + match picker.near_duplicates(&ids) { + Ok(pairs) => { + for pair in &pairs { + bindings.record_conflict( + pair.first().id(), + pair.second().id(), + f64::from(pair.similarity()), + ); + } + } + Err(error) => { + tracing::warn!(%error, "the near-duplicate conflict scan failed; none recorded"); + } + } +} + /// Builds the run's fuzzy-fill picker: the environment picker's loaded /// model re-indexed over the assembled catalog, so a fuzzy slot resolves /// only among tools the activated capabilities contributed. Returns diff --git a/crates/promptforge-api/src/execute/gateway.rs b/crates/promptforge-api/src/execute/gateway.rs index 5ba532d9..036f1129 100644 --- a/crates/promptforge-api/src/execute/gateway.rs +++ b/crates/promptforge-api/src/execute/gateway.rs @@ -1,56 +1,10 @@ -//! Gateway client acquisition and the live capability resolution context. - -use std::fmt; - -use promptforge_tool_picker::ToolPicker; +//! Gateway client acquisition. use crate::client::GatewayClient; -use crate::model::ModelCatalog; -use crate::tools::ToolCatalog; use crate::{Error, Result}; use super::config::RunLimits; -/// Live capability inputs for the parse-to-run execution path. -/// -/// Crate-internal: the public interface carries these on the -/// [`Environment`](super::Environment), which installs them on the -/// [`RunContext`](super::RunContext) before the free [`run`](super::run) -/// borrows them back into this borrowed shape for the live H1 pass. -#[derive(Clone, Copy)] -pub(crate) struct ResolutionContext<'a> { - /// 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. - pub(crate) tools: &'a ToolCatalog, -} - -impl<'a> ResolutionContext<'a> { - /// Builds a resolution context from an optional live picker, a model - /// catalog, and a tool catalog. - pub(crate) fn new( - picker: Option<&'a ToolPicker>, - models: &'a ModelCatalog, - tools: &'a ToolCatalog, - ) -> ResolutionContext<'a> { - ResolutionContext { - picker, - models, - tools, - } - } -} - -impl fmt::Debug for ResolutionContext<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ResolutionContext").finish_non_exhaustive() - } -} - /// Builds a gateway client from the environment with the run's HTTP limits /// applied, so a lazily created client honors the same timeout and body cap as /// a caller-supplied one. diff --git a/crates/promptforge-api/src/execute/scheduler.rs b/crates/promptforge-api/src/execute/scheduler.rs index fc36a97a..b0107fff 100644 --- a/crates/promptforge-api/src/execute/scheduler.rs +++ b/crates/promptforge-api/src/execute/scheduler.rs @@ -31,12 +31,13 @@ //! takes the next run-global id, and a jump transfers control - a sibling //! move within the chain's slice, or a descent into the jumper's child //! slice with the parent position suspended on the chain's own position -//! stack until the child level exhausts. A drive armed with -//! [`Scheduler::with_live_h1`] runs the live H1 pass first: the prompt's -//! H1 blocks as the driver loop's first chain, under the live pass's rules -//! (id 0, no section observations, a jump is an error, a scalar return -//! short-circuits the run), with the root walk starting from the H1 `var` -//! hand-off. A `fanout` request forks N arm chains (one per collection +//! stack until the child level exhausts. A prompt with H1 blocks runs them +//! first as section 0: the driver loop's first chain, under the walk's +//! rules with three deltas - the frame keeps id 0, a scalar return +//! short-circuits the run, and a Lua failure is the prompt's failed hard +//! gate, mapped to [`Error::RequirementsUnmet`] - with the root walk +//! starting from the H1 `var` hand-off. A `fanout` request forks N arm +//! chains (one per collection //! member) interleaved by the driver: at most the run's //! `max_fanout_concurrency` arms are active at once, each arm runs the //! same walk machinery as any chain over the worker's blocks, and the join @@ -69,15 +70,13 @@ use crate::fanout; use crate::fanout::ArmFinalizer; use crate::input::{INPUT_UNAVAILABLE_FALLBACK, InputOutcome}; use crate::lua::{ - CoroStep, LuaBlockResult, LuaFanoutResult, LuaProgram, MessageRecord, OverflowReason, - ScriptReport, SectionVm, UserInputOutcome, append_message_record, current_tool_bindings, - dispatch_tool, invoke_selected, project_messages, resolve_model_binding, run_store_op, - shim_live_h1_models, + CoroStep, LuaBlockResult, LuaFanoutResult, MessageRecord, OverflowReason, ScriptReport, + UserInputOutcome, append_message_record, current_tool_bindings, dispatch_tool, invoke_selected, + project_messages, resolve_model_binding, run_store_op, }; use crate::model::ModelBinding; use crate::observe::{Observation, detail}; use crate::parser::{Block, Prompt, Section}; -use crate::resolve::RuntimeResolution; use crate::store::{Access, Store, StoreError}; use crate::tools::ToolId; use crate::{Error, Result, cancel, subst}; @@ -86,7 +85,7 @@ use super::context::RunState; use super::engine::{ JumpTarget, home_without, resolve_jump_target, section_position, visible_sections, }; -use super::gateway::{GatewaySource, ResolutionContext}; +use super::gateway::GatewaySource; use super::protocol::{Answer, Request, StoreOp, ToolCallOutcome, YieldParse}; /// The most precise prompt-source line known for `blocks`: the first @@ -376,16 +375,17 @@ struct Chain<'a> { /// the same walk machinery as any chain, and its finish writes its /// join's result slot instead of a call answer. arm: Option>, - /// The live H1 pass marker: the prompt's H1 blocks under its title. - /// `Some` chains run the live pass's rules instead of the walk's: the - /// frame keeps id 0, no section observations fire, a recorded jump is - /// an error, a scalar return short-circuits the whole run, and the - /// pass's end starts the root walk with the H1 `var` hand-off. The - /// `slice`/`index` walk position stays empty and unused. + /// The H1 marker: the prompt's H1 blocks under its title - section 0. + /// `Some` chains run the walk's rules with three deltas: the frame + /// keeps id 0 (no section observations fire), a scalar return + /// short-circuits the whole run, and the pass's end starts the root + /// walk with the H1 `var` hand-off. The `slice`/`index` walk position + /// stays empty and unused until a jump out of H1 starts the walk at + /// the resolved target. h1: Option<&'a [Block]>, } -impl Chain<'_> { +impl<'a> Chain<'a> { /// The chain's access capability for section-VM installation. A live /// chain always holds one; `finish` and `abort_subtree` take it at /// chain end. @@ -399,9 +399,9 @@ impl Chain<'_> { .ok_or(Error::internal("a live chain holds its access capability")) } - /// The chain's current block sequence: the live H1 pass's blocks, or + /// The chain's current block sequence: the H1 pass's blocks, or /// the current section's blocks on the walk. - fn blocks(&self) -> &[Block] { + fn blocks(&self) -> &'a [Block] { match &self.h1 { Some(blocks) => blocks, None => self.slice[self.index].blocks(), @@ -510,12 +510,6 @@ pub(crate) struct Scheduler<'a> { /// The run's gateway source: chains resolve their client slot through /// it on first inference. client: GatewaySource, - /// The live H1 pass's run-scoped capability resolution: `Some` when the - /// scheduler runs the H1 pass before the walk (the run's shape), - /// `None` for a walk-only drive whose shared sets were filled another - /// way. One resolution serves the whole pass, so its decision cache - /// keeps the single-flight guarantee across blocks and resumes. - h1_resolution: Option>, } /// Aborts every in-flight leaf task when the driver future is dropped @@ -555,27 +549,9 @@ impl<'a> Scheduler<'a> { next_request: 0, next_fanout: 0, client: GatewaySource::from_optional(client, ctx.limits()), - h1_resolution: None, } } - /// Arms the live H1 pass: the drive runs the prompt's H1 blocks as the - /// first chain, under the live pass's rules, before the root walk - /// starts from the H1 hand-off. `resolution` carries the run's live - /// picker and catalogs; the pass's binds write the run's shared sets, - /// which the walk reads through the context's views. - #[must_use] - pub(crate) fn with_live_h1(mut self, resolution: ResolutionContext<'a>) -> Self { - self.h1_resolution = Some(RuntimeResolution::new( - resolution.picker, - resolution.tools, - resolution.models, - self.ctx.tool_set(), - self.ctx.model_set(), - )); - self - } - /// Shrinks the chain-count bound so a test can drive the /// [`start_chain`](Self::start_chain) overflow path. #[cfg(test)] @@ -592,10 +568,9 @@ impl<'a> Scheduler<'a> { .expect("the scheduler holds its own receiver"); } - /// Drives the run until it ends and returns the run's result: the live - /// H1 pass first when the scheduler was armed with - /// [`with_live_h1`](Self::with_live_h1), then the root chain over the - /// prompt's sections. + /// Drives the run until it ends and returns the run's result: the H1 + /// pass first when the prompt has H1 blocks, then the root chain over + /// the prompt's sections. /// /// Leaf dispatch spawns plain tasks (not `spawn_local`): an infer task /// touches no scheduler state and no Lua value - it awaits one gateway @@ -638,15 +613,18 @@ impl<'a> Scheduler<'a> { } async fn drive_inner(&mut self) -> Result { - if self.h1_resolution.is_some() { - let h1 = self.start_live_h1()?; - self.ready.push_back(h1); - } else { + // The H1 pass runs when the prompt has H1 blocks; an H1-less prompt + // goes straight to the walk, so its shared library never pays for a + // throwaway section-0 replay. + if self.ctx.prompt().h1_blocks().is_empty() { let sections = self.ctx.prompt().sections(); if sections.is_empty() { return Ok(GENERIC_COMPLETION.to_owned()); } self.start_root_walk(sections, &serde_json::json!({}))?; + } else { + let h1 = self.start_live_h1()?; + self.ready.push_back(h1); } let mut root_result = None; loop { @@ -820,9 +798,9 @@ impl<'a> Scheduler<'a> { Ok(()) } - /// Starts the live H1 pass as the driver loop's first chain: the - /// prompt's H1 blocks under its title, driven through the same - /// coroutine machinery as any section under the live pass's rules. + /// Starts the H1 pass as the driver loop's first chain: the prompt's + /// H1 blocks under its title - section 0 - driven through the same + /// coroutine machinery as any section. /// /// # Errors /// Returns [`Error::Internal`] when the run's chain count exceeds `u32`, @@ -864,22 +842,28 @@ impl<'a> Scheduler<'a> { Ok(id) } - /// Ends the live H1 pass at its fall-through: the final `var` read back + /// Ends the H1 pass at its fall-through: the final `var` read back /// while the VM is live, then the frame drops unarmed - the /// pass never arms completion, so `SECTION_FINISHED` never fires for - /// it. The root walk then starts from the `var` hand-off under the - /// walk's own context fork; with no sections the run's result is the - /// shared generic completion. + /// it. The root walk then starts from the `var` hand-off at section + /// `start` (0 on a fall-through, the resolved target on a jump out) + /// under the walk's own context fork; with no sections the run's + /// result is the shared generic completion. /// /// # Errors /// Returns [`Error::Lua`] when the final `var` read-back fails, /// [`Error::TimestampFormat`] when the walk's `when` fails to format, /// [`Error::Store`] when the backend refuses the walk's acquisition, /// or [`Error::Internal`] when the chain holds no frame. - fn end_live_h1(&mut self, id: ChainId, root_result: &mut Option>) -> Result<()> { + fn end_live_h1( + &mut self, + id: ChainId, + root_result: &mut Option>, + start: usize, + ) -> Result<()> { let chain = &mut self.chains[id.index()]; let Some(mut frame) = chain.frame.take() else { - return Err(Error::internal("the live H1 pass ends with a live frame")); + return Err(Error::internal("the H1 pass ends with a live frame")); }; let var = frame.read_var()?; drop(frame); @@ -892,15 +876,39 @@ impl<'a> Scheduler<'a> { return Ok(()); } // The H1-to-walk handoff: the walk's context takes its live `when`; - // H1's binds already landed in the shared sets the views read. + // H1's prompt-wide records already landed in the shared sets the + // views read. let when = now_rfc3339_checked()?; let walk_ctx = self.ctx.with_walk_state(&when); - let root = self.start_chain(walk_ctx, sections, 0, None, &var, 0, None)?; + let root = self.start_chain(walk_ctx, sections, start, None, &var, 0, None)?; self.install_root_slots(root)?; self.ready.push_back(root); Ok(()) } + /// Ends the H1 pass on a jump out: the heading resolves against the + /// top-level sections (H1's visible set - section 0 excludes nothing + /// and has no children), then the pass ends and the root walk starts + /// at the target. + /// + /// # Errors + /// Returns [`Error::Lua`] when the heading is malformed, matches no + /// top-level section, or matches more than one; the pass's own ending + /// can fail as [`end_live_h1`](Self::end_live_h1) documents. + fn end_live_h1_at_jump( + &mut self, + id: ChainId, + heading: &str, + root_result: &mut Option>, + ) -> Result<()> { + let sections = self.ctx.prompt().sections(); + let target = fanout::resolve_sibling(heading, sections)?; + let start = section_position(sections, target).ok_or(Error::internal( + "a resolved H1 jump target is absent from the top-level slice", + ))?; + self.end_live_h1(id, root_result, start) + } + /// Enters the chain's next section and reports whether one was entered: /// constructs the frame with the next run-global id, seeded from /// the chain's `var` and client slots. The pending Markdown buffer @@ -915,9 +923,10 @@ impl<'a> Scheduler<'a> { let chain = &mut self.chains[id.index()]; chain.pending_prose = None; if chain.h1.is_some() { - // The live H1 pass enters its frame exactly once: id 0 under - // the prompt's title, the control-stub surface, the shim base, - // and no SECTION_STARTED - the pass is not a walked section. + // The H1 pass enters its frame exactly once: section 0 under + // the prompt's title, through the same install path as any + // section - and no SECTION_STARTED, the pass is not a walked + // section. let frame = SectionContext::new_live_h1(&chain.ctx, chain.access()?)?; chain.frame = Some(frame); chain.block = 0; @@ -1070,7 +1079,7 @@ impl<'a> Scheduler<'a> { } Advance::SectionEnd => { if self.chains[id.index()].h1.is_some() { - self.end_live_h1(id, root_result)?; + self.end_live_h1(id, root_result, 0)?; } else { self.end_section(id)?; self.ready.push_back(id); @@ -1080,9 +1089,7 @@ impl<'a> Scheduler<'a> { } } - /// Resumes a chain's suspended coroutine with its delivered answer: the - /// live H1 pass resumes inside a fresh resolver scope, a walked section - /// resumes directly. + /// Resumes a chain's suspended coroutine with its delivered answer. async fn resume_block( &mut self, id: ChainId, @@ -1091,16 +1098,7 @@ impl<'a> Scheduler<'a> { root_result: &mut Option>, ) -> Result<()> { let chain = &self.chains[id.index()]; - if chain.h1.is_some() { - let (result, callback_error) = self.h1_scoped_step(id, |vm, program| { - vm.resume_block_coro_answer(program, thread, answer) - })?; - return self - .finish_h1_step(id, result, callback_error, root_result) - .await; - } - let slice = chain.slice; - let Block::Lua(program) = &slice[chain.index].blocks()[chain.block] else { + let Block::Lua(program) = &chain.blocks()[chain.block] else { return Err(Error::internal("a suspended coroutine's block is Lua")); }; let frame = chain @@ -1115,8 +1113,7 @@ impl<'a> Scheduler<'a> { /// Starts the chain's current Lua block as a fresh coroutine: the /// pending Markdown buffer installs as the block's fresh read-only - /// lazy `prose` template first, then the live H1 pass starts inside a - /// fresh resolver scope while a walked section starts directly. The + /// lazy `prose` template first. The /// driver owns the chunk observation /// boundaries: STARTED at the block's start, SUCCEEDED or FAILED when /// its coroutine finally returns or fails - a suspension is neither. @@ -1139,22 +1136,9 @@ impl<'a> Scheduler<'a> { observer.observe(&execution, &name, detail::LUA_CHUNK_FAILED); return Err(error); } - if chain.h1.is_some() { - let (result, callback_error) = self.h1_scoped_step(id, |vm, program| { - vm.start_block_coro(program).map_err(Error::from) - })?; - return self - .finish_h1_step(id, result, callback_error, root_result) - .await; - } - let slice = chain.slice; - let Block::Lua(program) = &slice[chain.index].blocks()[chain.block] else { + let Block::Lua(program) = &chain.blocks()[chain.block] else { return Err(Error::internal("the advance matched the block kind")); }; - let frame = chain - .frame - .as_ref() - .ok_or(Error::internal("a live chain holds its frame"))?; let result = frame.vm()?.start_block_coro(program).map_err(Error::from); self.handle_coro_result(id, result, root_result).await } @@ -1281,6 +1265,21 @@ impl<'a> Scheduler<'a> { /// [`fanout::resolve_sibling`]). fn resolve_chain_target(&self, id: ChainId, heading: &str) -> Result> { let chain = &self.chains[id.index()]; + if chain.h1.is_some() { + // H1 is section 0: its visible set is the whole top-level + // slice - it excludes nothing and has no children, so every + // target is a flat index into that slice. + let sections = self.ctx.prompt().sections(); + let target = fanout::resolve_sibling(heading, sections)?; + let index = section_position(sections, target).ok_or(Error::internal( + "a resolved H1 target is absent from the top-level slice", + ))?; + return Ok(ChainTarget { + slice: sections, + index, + child: false, + }); + } if let Some(arm) = &chain.arm && arm.at_worker { @@ -1334,6 +1333,26 @@ impl<'a> Scheduler<'a> { Ok(step) => step, Err(error) => { observer.observe(&execution, &name, detail::LUA_CHUNK_FAILED); + // A failed H1 assertion ends the run before the walk: + // H1's remaining job is the prompt's hard gates, so the + // prompt chunk's own Lua failure IS the failed assertion + // and its message is the failure notice. Only the chunk's + // error remaps: the machinery around it (the shared + // replay, the final `var` read-back, jump-target + // resolution) keeps its own kind - a prompt bug under + // `Error::Lua`, not an unsatisfiable environment. Fatal + // run conditions (cancellation, the claims violation) + // keep their own classification either way. + let error = if self.chains[id.index()].h1.is_some() { + match error { + Error::Lua(_) | Error::LuaRuntime { .. } => Error::RequirementsUnmet { + notice: error.to_string(), + }, + other => other, + } + } else { + error + }; return Err(error); } }; @@ -1368,16 +1387,11 @@ impl<'a> Scheduler<'a> { CoroStep::Done(LuaBlockResult::Jump(heading)) => { // A jump is a control transfer, not a failure: the chunk // boundary reports success and the walk moves to the - // resolved target. + // resolved target. A jump out of H1 ends the pass and + // starts the walk at the target. observer.observe(&execution, &name, detail::LUA_CHUNK_SUCCEEDED); if self.chains[id.index()].h1.is_some() { - // The H1 VM carries only the stub control globals, which - // raise before anything is recorded; this arm stays - // defensive against a recorded jump, exactly as the - // legacy `run_live_h1_block` maps it. - return Err(Error::Lua(format!( - "jump({heading}) is not available in live H1 Lua" - ))); + return self.end_live_h1_at_jump(id, &heading, root_result); } self.apply_jump(id, &heading)?; self.ready.push_back(id); @@ -1388,9 +1402,9 @@ impl<'a> Scheduler<'a> { if self.chains[id.index()].h1.is_some() { let chain = &mut self.chains[id.index()]; if let Some(value) = value { - // A scalar return from the live H1 pass + // A scalar return from the H1 pass // short-circuits the whole run. The final `var` - // read-back runs here exactly as the legacy pass + // read-back runs here exactly as the walk // reads it on every exit, so a reassigned `var` // global fails the run instead of returning the // value; the frame then drops unarmed - the pass @@ -1404,7 +1418,7 @@ impl<'a> Scheduler<'a> { *root_result = Some(Ok(value)); return Ok(()); } - // Live H1 does not read the `reply` global back after a + // H1 does not read the `reply` global back after a // Lua block: the pass's reply slot rolls forward through // prose alone. chain.block += 1; @@ -1424,92 +1438,6 @@ impl<'a> Scheduler<'a> { } } - /// Runs one live H1 coroutine step (a block's start or a suspension's - /// resume) inside a fresh Lua scope with the capability resolvers and - /// the live models shim wrap installed. The step's outcome and the - /// captured resolver callback error return separately, so the caller - /// observes the chunk's own boundary first and then applies the legacy - /// `run_live_h1_block` contract: a typed resolver error captured by a - /// callback fails the block even when the chunk caught the Lua error - /// itself. - /// - /// The resolvers reinstall on every step because their callbacks are - /// scoped: a suspended coroutine outlives the scope it started in, so - /// each resume enters a fresh scope with fresh live tables before the - /// thread runs again. The resolution's decision cache is run-scoped, so - /// reinstalling never re-queries the picker. One legacy edge narrows - /// here: an author alias saved from the `tools`/`models` table (say - /// `local bind = models.bind`) dies with its scope, so calling it after - /// a suspension fails where the legacy block-scoped install allowed it - /// within one block. - /// - /// # Errors - /// Returns [`Error::Internal`] when the chain is not the live H1 chain - /// or the step machinery fails; the step's own outcome and the - /// captured resolver callback error ride the `Ok` pair. - fn h1_scoped_step( - &self, - id: ChainId, - run: impl FnOnce(&SectionVm, &LuaProgram) -> Result, - ) -> Result<(Result, Option)> { - let chain = &self.chains[id.index()]; - let Some(blocks) = chain.h1 else { - return Err(Error::internal( - "the scoped step belongs to the live H1 pass", - )); - }; - let Block::Lua(program) = &blocks[chain.block] else { - return Err(Error::internal("a suspended coroutine's block is Lua")); - }; - let resolution = self - .h1_resolution - .as_ref() - .ok_or(Error::internal("the live H1 pass holds its resolution"))?; - let frame = chain - .frame - .as_ref() - .ok_or(Error::internal("a live chain holds its frame"))?; - let vm = frame.vm()?; - let mut outcome = None; - let scoped = vm.lua().scope(|scope| { - resolution - .install(vm.lua(), scope) - .map_err(mlua::Error::external)?; - shim_live_h1_models(vm.lua()).map_err(mlua::Error::external)?; - outcome = Some(run(vm, program)); - Ok(()) - }); - let result = match scoped { - Ok(()) => outcome.ok_or(Error::internal("the scoped step records its outcome"))?, - Err(error) => Err(Error::lua(error)), - }; - // The outcome and the captured callback error travel separately: - // the outcome decides the chunk's observation boundary, and the - // callback error is reported after it, with precedence. - Ok((result, resolution.take_callback_error()?)) - } - - /// Applies one live H1 step's outcome, then its captured resolver - /// callback error: the outcome drives the chunk's observation boundary - /// (a chunk that caught the resolver's Lua error itself still reports - /// `LUA_CHUNK_SUCCEEDED`), and the callback error fails the run - /// afterward, taking precedence over the outcome - the legacy - /// `run_live_h1_block` mapping, where the callback check follows the - /// chunk's own boundary. - async fn finish_h1_step( - &mut self, - id: ChainId, - result: Result, - callback_error: Option, - root_result: &mut Option>, - ) -> Result<()> { - let outcome = self.handle_coro_result(id, result, root_result).await; - match callback_error { - Some(error) => Err(error), - None => outcome, - } - } - /// Dispatches one validated request from a suspended chain. /// /// # Errors @@ -1668,14 +1596,6 @@ impl<'a> Scheduler<'a> { args: serde_json::Value, ) -> Result<(RequestId, tokio::task::JoinHandle<()>)> { let chain = &mut self.chains[id.index()]; - if chain.h1.is_some() { - // Unreachable: section VMs alone install the `tools.call` shim, - // the H1 VM never does, and stripped coroutines make a - // hand-rolled yield impossible. - return Err(Error::internal( - "the live H1 pass cannot dispatch a tool_call request", - )); - } let tool_set = chain.ctx.tool_set_snapshot()?; let Some(binding) = tool_set.binding(alias).cloned() else { return Err(Error::UnboundToolCall { @@ -1929,14 +1849,6 @@ impl<'a> Scheduler<'a> { on_delta, ) = { let chain = &mut self.chains[id.index()]; - if chain.h1.is_some() { - // Unreachable: section VMs alone install the models.loop - // shim, the H1 VM never does, and stripped coroutines make a - // hand-rolled yield impossible. - return Err(Error::internal( - "the live H1 pass cannot dispatch a loop request", - )); - } let execution = chain.ctx.execution().to_owned(); let section = chain.section_name().to_owned(); let binding = if let Some(binding) = binding { @@ -2110,14 +2022,6 @@ impl<'a> Scheduler<'a> { var: &serde_json::Value, ) -> Result { let chain = &self.chains[id.index()]; - if chain.h1.is_some() { - // Unreachable: the H1 control stubs raise before anything can - // yield. A panic on the empty walk slice would be worse than - // the typed invariant error. - return Err(Error::internal( - "the live H1 pass cannot dispatch a call request", - )); - } let depth = chain.call_depth + 1; if depth > MAX_CALL_DEPTH { return Err(Error::Lua(format!( @@ -2186,14 +2090,6 @@ impl<'a> Scheduler<'a> { var: &serde_json::Value, ) -> Result<()> { let chain = &self.chains[id.index()]; - if chain.h1.is_some() { - // Unreachable: the H1 control stubs raise before anything can - // yield. A panic on the empty walk slice would be worse than - // the typed invariant error. - return Err(Error::internal( - "the live H1 pass cannot dispatch a fanout request", - )); - } let depth = chain.call_depth + 1; if depth > MAX_CALL_DEPTH { return Err(Error::Lua(format!( @@ -2211,7 +2107,16 @@ impl<'a> Scheduler<'a> { // An at-worker arm's fanout resolves over the worker's visible set // (handled inside `resolve_chain_target`); the new arms in turn // treat the worker as their caller. + // + // H1 has no position in the top-level slice: the worker's own + // position stands in as the caller's, so the arm's visible set + // comes out as the worker's siblings plus its children either way. + let h1_caller = chain.h1.is_some(); let (caller_slice, caller_index) = match &chain.arm { + _ if h1_caller => { + let target = self.resolve_chain_target(id, worker_name)?; + (target.slice, target.index) + } Some(arm) if arm.at_worker => (arm.worker_slice, arm.worker_index), _ => (chain.slice, chain.index), }; diff --git a/crates/promptforge-api/src/execute/section_context.rs b/crates/promptforge-api/src/execute/section_context.rs index 7ce6dd9b..49217c2d 100644 --- a/crates/promptforge-api/src/execute/section_context.rs +++ b/crates/promptforge-api/src/execute/section_context.rs @@ -23,10 +23,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicU32; use crate::debug::DebugCapture; -use crate::lua::{ - ProseState, SectionVm, ToolBinding, ToolCallCounts, install_live_h1_shim_base, - install_store_shims, -}; +use crate::lua::{ProseState, SectionVm, ToolBinding, ToolCallCounts}; use crate::observe::{Observer, detail}; use crate::parser::Section; use crate::store::Access; @@ -125,12 +122,10 @@ impl SectionContext { let sys = ctx.sys_json(section_id, section.name())?; ctx.observer() .observe(ctx.execution(), section.name(), detail::SECTION_STARTED); - let tool_set = ctx.tool_set_snapshot()?; - let model_set = ctx.model_set_snapshot()?; let mut vm = SectionVm::new_for_section( ctx.nonce(), - &tool_set, - &model_set, + &ctx.tool_set(), + &ctx.model_set(), ctx.execution(), ctx.observer().as_ref(), section.name(), @@ -180,16 +175,19 @@ impl SectionContext { }) } - /// Constructs the frame for the live H1 pass and runs its setup - /// preamble: the `sys` JSON (id 0 under the prompt's title), VM - /// construction and limits, host injection, the host APIs, the H1 - /// control-global stubs, and the live H1 shim base. + /// Constructs the frame for the H1 pass - section 0 - through the same + /// install path as any walked section: the `sys` JSON (id 0 under the + /// prompt's title, stamped with its own `now` because the walk's `when` + /// does not exist yet), VM construction over the run's shared sets, + /// limits, and the shared setup half (host injection, host APIs, the + /// control surface, the coroutine shims, the shared replay, the + /// captured alias bindings). /// - /// H1 is the level-1 section: it runs first and is never re-entered, so - /// the frame seeds an empty `var` and no item. The scheduler answers the pass's `models.infer` yields (with - /// or without a leading handle) through its driver, so the shim base - /// keeps the control stubs, - /// which raise before anything structural can yield. + /// H1's only deltas from a walked section: id 0, no `SECTION_STARTED` + /// observation (the pass is not a walked section), an empty `var` seed + /// (it runs first and is never re-entered), and a `list_from_section` + /// visible set spanning the whole top-level slice - section 0 excludes + /// nothing and has no children. /// /// # Errors /// Returns the [`Error`](crate::Error) of whichever step failed. A VM @@ -207,19 +205,28 @@ impl SectionContext { ctx.execution(), ctx.prompt().sections().len(), ); - let mut vm = SectionVm::new(ctx.nonce(), ctx.execution(), ctx.observer().as_ref(), title)?; + let mut vm = SectionVm::new_for_section( + ctx.nonce(), + &ctx.tool_set(), + &ctx.model_set(), + ctx.execution(), + ctx.observer().as_ref(), + title, + )?; // A limits failure propagates bare: no teardown runs here, so no // LUA_TEARDOWN_* observation fires on this path. vm.apply_lua_limits( ctx.limits().lua_memory().get(), ctx.limits().lua_logs().get(), )?; + // H1's visible set is the whole top-level slice: section 0 + // excludes nothing and has no children. + let visible = ctx.prompt().sections().to_vec(); + let list_callback = move |heading: String| list_items_from_visible(&heading, &visible); + let setup = ctx.vm_setup(&sys, VmSeed::default(), access, title); // Setup runs on the bare VM so a failure tears it down here: the // frame does not exist yet, so its `Drop` cannot own this path. - if let Err(error) = setup_live_h1(&mut vm, ctx, access, &sys, title) - .and_then(|()| install_live_h1_shim_base(vm.lua()).map_err(Error::from)) - .and_then(|()| install_store_shims(vm.lua()).map_err(Error::from)) - { + if let Err(error) = setup_section_vm(&mut vm, &setup, list_callback) { vm.teardown(ctx.observer().as_ref(), title); return Err(error); } @@ -270,12 +277,10 @@ impl SectionContext { item: serde_json::Value, var: &serde_json::Value, ) -> Result { - let tool_set = ctx.tool_set_snapshot()?; - let model_set = ctx.model_set_snapshot()?; let mut vm = SectionVm::new_for_section( ctx.nonce(), - &tool_set, - &model_set, + &ctx.tool_set(), + &ctx.model_set(), ctx.execution(), ctx.observer().as_ref(), worker.name(), @@ -487,24 +492,6 @@ fn install_section_scope( Ok(()) } -/// The fallible setup half of the live H1 lifecycle: host injection, the -/// host APIs, and the control-global stubs. One function, so the -/// constructor's single teardown-on-error branch covers every step. -/// -/// # Errors -/// Returns the [`Error`](crate::Error) of whichever step failed. -fn setup_live_h1( - vm: &mut SectionVm, - ctx: &RunState, - access: &Arc, - sys: &serde_json::Value, - title: &str, -) -> Result<()> { - vm.inject_host(ctx.args(), sys, access)?; - vm.install_host_apis(ctx.observer(), title)?; - vm.install_h1_control_stubs().map_err(Error::from) -} - impl Drop for SectionContext { fn drop(&mut self) { // The single teardown boundary: every exit path - success, error, diff --git a/crates/promptforge-api/src/execute/tests/debug_and_counts.rs b/crates/promptforge-api/src/execute/tests/debug_and_counts.rs index 7b1ac826..1efb4e4a 100644 --- a/crates/promptforge-api/src/execute/tests/debug_and_counts.rs +++ b/crates/promptforge-api/src/execute/tests/debug_and_counts.rs @@ -201,10 +201,9 @@ async fn tool_calls_count_increments_on_successful_dispatch() { "canonical_echo", "Echo a test value.", )); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/echo\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ - tools.bind('echo', 'echo tool')\n\ - models.default('writer', 'A general model for tests')\n```\n\n\ + models.default('writer')\n```\n\n\ ## Only\n\n\ ```lua\n\ tools.call('echo', { value = 'x' })\n\ @@ -288,11 +287,9 @@ async fn tool_calls_count_zero_for_uncalled_alias_fails_epilog_assert() { // The first script dispatch installs the counts seeded from the // effective scope, so an added but uncalled alias reads as 0 and an // author assert on it fails the run with its own message. - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n search: tests/tools/search\n other: tests/tools/other\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ - tools.bind('search', 'search tool')\n\ - tools.bind('other', 'other tool')\n\ - models.default('writer', 'A general model for tests')\n```\n\n\ + models.default('writer')\n```\n\n\ ## Only\n\n```lua\n\ tools.add('search')\n\ local _ = tools.call('other', { value = 'x' })\n\ @@ -322,10 +319,9 @@ async fn tool_calls_count_zero_for_uncalled_alias_fails_epilog_assert() { #[tokio::test] async fn tool_calls_typo_alias_is_a_hard_error_with_seeded_set() { - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n search: tests/tools/search\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ - tools.bind('search', 'search tool')\n\ - models.default('writer', 'A general model for tests')\n```\n\n\ + models.default('writer')\n```\n\n\ ## Only\n\n```lua\n\ tools.add('search')\n\ local _ = tools.call('search', { value = 'x' })\n\ @@ -425,7 +421,7 @@ async fn model_calling_global_but_unscoped_tool_is_a_hard_error() { in_scope, } => { assert_eq!(name, "global_tool"); - assert!(*global_exists, "the alias was declared by tools.bind"); + assert!(*global_exists, "the alias is a bound tool slot"); assert!( in_scope.contains(&"scoped".to_string()), "in_scope must list the scoped alias: {in_scope:?}" @@ -439,7 +435,7 @@ async fn model_calling_global_but_unscoped_tool_is_a_hard_error() { } let msg = error.to_string(); assert!( - msg.contains("declared by tools.bind but not added"), + msg.contains("bound tool slot but was not added"), "error message must hint declared-but-unscoped: {msg}" ); } @@ -505,10 +501,7 @@ async fn model_calling_pure_unknown_tool_is_a_hard_error() { in_scope, } => { assert_eq!(name, "nonexistent"); - assert!( - !*global_exists, - "the alias was never declared by tools.bind" - ); + assert!(!*global_exists, "the alias was never a bound tool slot"); assert!( in_scope.contains(&"echo".to_string()), "in_scope must list the scoped alias: {in_scope:?}" @@ -518,7 +511,7 @@ async fn model_calling_pure_unknown_tool_is_a_hard_error() { } let msg = error.to_string(); assert!( - !msg.contains("declared by tools.bind but not added"), + !msg.contains("bound tool slot but was not added"), "pure unknown must not hint declared-but-unscoped: {msg}" ); } diff --git a/crates/promptforge-api/src/execute/tests/exec_flow.rs b/crates/promptforge-api/src/execute/tests/exec_flow.rs index 4e28d2b6..6c2caf81 100644 --- a/crates/promptforge-api/src/execute/tests/exec_flow.rs +++ b/crates/promptforge-api/src/execute/tests/exec_flow.rs @@ -1561,7 +1561,7 @@ return models.infer(models.get('ghost'), 'ping')\n\ .expect_err("an unknown model alias inside an arm must fail loudly"); let rendered = error.to_string(); assert!( - rendered.contains("models.get alias \"ghost\" was not declared"), + rendered.contains("models.get alias \"ghost\" is not a bound model role"), "the unknown alias must be named: {rendered}" ); } @@ -1850,11 +1850,11 @@ async fn missing_bare_global_in_prose_errors() { ); } -/// The H1 VM's control globals are stubs: H1 runs before sections exist, so -/// calling one fails the run with a message naming the cause instead of -/// Lua's stock nil-call error. +/// The control globals work in H1 (section 0): a `call` naming no +/// top-level section fails the run with the resolution error naming the +/// target. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn call_is_a_clear_error_on_the_h1() { +async fn call_from_h1_to_an_unknown_section_fails_the_run() { let md = flow_prompt!( "\ # Test prompt\n\n\ @@ -1864,18 +1864,18 @@ call('## Nope')\n\ ); let error = run_offline(md) .await - .expect_err("call from the H1 must fail with the stub error"); + .expect_err("call from H1 to an unknown section must fail"); let rendered = error.to_string(); assert!( - rendered.contains("only available in sections"), - "the stub error must name the cause: {rendered}" + rendered.contains("## Nope"), + "the resolution error names the missing section: {rendered}" ); } -/// `jump` from the H1 hits the same stub: the run fails with the clear -/// message, never a recorded jump. +/// `jump` out of H1 names a top-level section; an unknown target fails the +/// run with the resolution error. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn jump_is_a_clear_error_on_the_h1() { +async fn jump_from_h1_to_an_unknown_section_fails_the_run() { let md = flow_prompt!( "\ # Test prompt\n\n\ @@ -1885,17 +1885,18 @@ jump('## Nope')\n\ ); let error = run_offline(md) .await - .expect_err("jump from the H1 must fail with the stub error"); + .expect_err("jump from H1 to an unknown section must fail"); let rendered = error.to_string(); assert!( - rendered.contains("only available in sections"), - "the stub error must name the cause: {rendered}" + rendered.contains("## Nope"), + "the resolution error names the missing section: {rendered}" ); } -/// `fanout` from the H1 hits the same stub. +/// `fanout` from H1 resolves its worker against the top-level sections; an +/// unknown worker fails the run with the resolution error. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn fanout_is_a_clear_error_on_the_h1() { +async fn fanout_from_h1_to_an_unknown_section_fails_the_run() { let md = flow_prompt!( "\ # Test prompt\n\n\ @@ -1905,17 +1906,18 @@ fanout('## Nope', {'a'})\n\ ); let error = run_offline(md) .await - .expect_err("fanout from the H1 must fail with the stub error"); + .expect_err("fanout from H1 to an unknown section must fail"); let rendered = error.to_string(); assert!( - rendered.contains("only available in sections"), - "the stub error must name the cause: {rendered}" + rendered.contains("## Nope"), + "the resolution error names the missing section: {rendered}" ); } -/// `list_from_section` from the H1 hits the same stub. +/// `list_from_section` from H1 resolves over the whole top-level slice; an +/// unknown target fails the run with the resolution error. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_from_section_is_a_clear_error_on_the_h1() { +async fn list_from_section_from_h1_to_an_unknown_section_fails_the_run() { let md = flow_prompt!( "\ # Test prompt\n\n\ @@ -1925,11 +1927,11 @@ list_from_section('## Nope')\n\ ); let error = run_offline(md) .await - .expect_err("list_from_section from the H1 must fail with the stub error"); + .expect_err("list_from_section from H1 to an unknown section must fail"); let rendered = error.to_string(); assert!( - rendered.contains("only available in sections"), - "the stub error must name the cause: {rendered}" + rendered.contains("## Nope"), + "the resolution error names the missing section: {rendered}" ); } @@ -2337,7 +2339,7 @@ async fn picker_less_context_runs_a_capability_free_prompt() { ## Only\n\n```lua\nreturn 'no capabilities'\n```\n" ); let test = fixture(md); - let env = Environment::new().models(test.models.clone()); + let env = Environment::new(); let RunResult::Ok(out) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await else { panic!("a capability-free prompt runs without a picker"); }; @@ -2354,7 +2356,7 @@ async fn default_run_context_store_handle_carries_the_stock_mount() { ## Second\n\n```lua\nreturn store.read('default.txt')\n```\n" ); let test = fixture(md); - let env = Environment::new().models(test.models.clone()); + let env = Environment::new(); let RunResult::Ok(out) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await else { panic!("the default store handle carries the stock mount"); }; @@ -2362,54 +2364,48 @@ async fn default_run_context_store_handle_carries_the_stock_mount() { } #[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!( +async fn advertising_an_unfilled_slot_fails_at_run_time() { + // A fuzzy slot with no picker to fill it stays unfilled at prepare; + // advertising the alias in a section is the run-time error prepare + // promised. + let md = concat!( + "---\nname: t\ndescription: d\npromptforge: 0\ntools:\n search:\n want: search the web\n---\n\n", "# Test prompt\n\n\ - ```lua\ntools.bind('search', 'search the web')\n```\n\n\ - ## Only\n\n```lua\nreturn 'unreachable'\n```\n" + ## Only\n\n```lua\ntools.add('search')\nreturn 'unreachable'\n```\n" ); let test = fixture(md); - let env = Environment::new().models(test.models.clone()); + let env = Environment::new(); let RunResult::Failure(error) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await else { - panic!("a tools.bind without a picker must fail"); + panic!("advertising an unfilled alias 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}" + error + .to_string() + .contains("tools.add alias \"search\" is not a bound tool slot"), + "the failure names the unfilled alias: {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. +async fn models_bind_is_gone_from_the_lua_surface() { + // `models.bind` is removed: binding is the frontmatter's. The legacy + // call is a nil call, and the failed H1 gate classifies as + // RequirementsUnmet. 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 env = Environment::new().models(test.models.clone()); + let test = fixture(md); + let env = Environment::new(); let RunResult::Failure(error) = env.run(&test.prompt, "", RunContext::new(EXECUTION)).await else { - panic!("a models.bind without a picker must fail"); + panic!("a models.bind call 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}" + RunErrorKind::RequirementsUnmet, + "the removed models.bind fails the H1 gate as RequirementsUnmet: {error:?}" ); } diff --git a/crates/promptforge-api/src/execute/tests/live_infer.rs b/crates/promptforge-api/src/execute/tests/live_infer.rs index 6695b673..6bebe15f 100644 --- a/crates/promptforge-api/src/execute/tests/live_infer.rs +++ b/crates/promptforge-api/src/execute/tests/live_infer.rs @@ -6,21 +6,17 @@ async fn live_h1_infer_runs_once() { let gateway = ScriptedGateway::start(vec![resp_text("h1 answer")]).await; let addr = gateway.addr(); - let source = "---\nname: live-h1\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: live-h1\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Live H1\n\n\ ```lua\n\ - local writer = models.default('writer', 'A general model for tests')\n\ + local writer = models.default('writer')\n\ var.answer = models.infer(writer, 'answer once')\n\ ```\n\n\ ## Result\n\n\ ```lua\nreturn var.answer\n```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let RunResult::Ok(out) = env.run(&prompt, "", to_context(gatewayed(addr))).await else { panic!("live H1 path must run"); }; @@ -37,10 +33,10 @@ async fn the_environment_client_serves_a_run_when_the_context_carries_none() { let gateway = ScriptedGateway::start(vec![resp_text("env answer")]).await; let addr = gateway.addr(); - let source = "---\nname: env-client\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: env-client\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Env Client\n\n\ ```lua\n\ - local writer = models.default('writer', 'A general model for tests')\n\ + local writer = models.default('writer')\n\ var.answer = models.infer(writer, 'answer once')\n\ ```\n\n\ ## Result\n\n\ @@ -48,8 +44,6 @@ async fn the_environment_client_serves_a_run_when_the_context_carries_none() { let prompt = parse(source); let env = Environment::new() .picker(empty_test_picker()) - .models(test_model_catalog()) - .tools(ToolCatalog::default()) .client(gateway_client(addr)); // The context deliberately carries no client: the defaulting in // `Environment::run` is the only path to the gateway. @@ -71,7 +65,7 @@ async fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { // whose substitution would fail or stay empty - discards at the pass's // end without requiring a model. Only an explicit `models.infer` of the // prose requires a binding. - let unread = "---\nname: empty-h1\ndescription: d\npromptforge: 0\n---\n\n\ + let unread = "---\nname: empty-h1\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Empty H1\n\n\ ```lua\nvar.omit = ''\n```\n\n\ {{ var.omit }}\n\n\ @@ -82,7 +76,7 @@ async fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { .expect("unread H1 prose must not require a model"); assert_eq!(out, "ok"); - let reading = "---\nname: read-h1\ndescription: d\npromptforge: 0\n---\n\n\ + let reading = "---\nname: read-h1\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Read H1\n\n\ ask\n\n\ ```lua\nreturn models.infer(prose)\n```\n"; @@ -95,34 +89,9 @@ async fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { ); } -#[tokio::test(flavor = "multi_thread")] -async fn caught_h1_callback_error_stops_before_a_later_block() { - let source = "---\nname: callback-drain\ndescription: d\npromptforge: 0\n---\n\n\ - # Callback Drain\n\n\ - ```lua\n\ - local ok = pcall(models.bind, 'missing', 'unavailable model')\n\ - assert(not ok)\n\ - ```\n\n\ - ```lua\nstore.write('later.txt', 'ran')\n```\n\n\ - ## Result\n\n\ - ```lua\nreturn 'unexpected'\n```\n"; - let store = TestStore::new(); - let error = super::run(&fixture(source), "", &[], &store, silent()) - .await - .expect_err("a caught resolver callback error must fail its own block"); - assert!( - matches!(error, Error::ModelAbsent { .. }), - "the current block's typed callback error must surface: {error}" - ); - assert!( - store.read("later.txt").is_err(), - "the later H1 block must not run after the callback error" - ); -} - #[tokio::test(flavor = "multi_thread")] async fn shared_function_resolves_host_globals_when_called() { - let source = "---\nname: shared-host\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: shared-host\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Shared Host\n\n\ ```lua shared\n\ function read_args() return args end\n\ @@ -131,11 +100,7 @@ async fn shared_function_resolves_host_globals_when_called() { ```lua\nreturn read_args()\n```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let RunResult::Ok(out) = env .run(&prompt, "later host value", to_context(silent())) .await @@ -152,8 +117,7 @@ async fn shared_library_calls_host_apis_at_load_time() { // host environment installed, so top-level shared code may use `store`, // `log`, and `args` at load. let picker = empty_test_picker(); - let models = test_model_catalog(); - let source = "---\nname: shared-host-load\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: shared-host-load\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Shared Host Load\n\n\ ```lua shared\n\ store.write('loaded.txt', args)\n\ @@ -162,10 +126,7 @@ async fn shared_library_calls_host_apis_at_load_time() { ## Result\n\n\ ```lua\nreturn store.read('loaded.txt')\n```\n"; let prompt = parse(source); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); // The multi-step path: prepare builds the run's own router, and the // test store wraps the prepared handle so the post-run assertion // reads what the run actually wrote. @@ -191,20 +152,11 @@ async fn shared_library_calls_host_apis_at_load_time() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn captured_bindings_reach_section_call_and_fanout_vms() { let echo = Arc::new(EchoTool); - let descriptor = ToolDescriptor::new( - PickerToolId::parse("tests/tools/echo").expect("fixture id is valid"), - echo.description(), - echo.parameters_schema(), - ); - let capability = - serde_json::to_string(&capability_for(&descriptor)).expect("serialize tool capability"); - let source = format!( - "---\nname: captured-bindings\ndescription: d\npromptforge: 0\n---\n\n\ + // The bound slots arrive from the frontmatter: the capability installs + // the tool, the exact slot binds the alias, and the captured alias + // globals install in every section VM - H1 never runs a bind. + let source = "---\nname: captured-bindings\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/echo\nmodels:\n writer: {}\n---\n\n\ # Captured Bindings\n\n\ - ```lua\n\ - echo = tools.bind('echo', {capability})\n\ - writer = models.bind('writer', 'A general model for tests')\n\ - ```\n\n\ ```lua shared\n\ function binding_names() return echo.name .. ':' .. writer.name end\n\ ```\n\n\ @@ -221,17 +173,10 @@ async fn captured_bindings_reach_section_call_and_fanout_vms() { - one\n\ - two\n\n\ ## Called\n\n\ - ```lua\nreturn binding_names()\n```\n" - ); - let prompt = parse(&source); - let picker = build_test_picker(Catalog::new(vec![descriptor]), PickerConfig::default()); - let models = test_model_catalog(); + ```lua\nreturn binding_names()\n```\n"; + let prompt = parse(source); let tools: [Arc; 1] = [echo]; - let catalog = ToolCatalog::new(&tools).expect("the fixture tool is unique"); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(catalog); + let env = Environment::new().registry(tools_registry(&tools)); let RunResult::Ok(out) = env.run(&prompt, "", to_context(silent())).await else { panic!("captured bindings must be installed in every section VM"); }; @@ -248,10 +193,10 @@ async fn live_h1_models_infer_resolves_the_default_model_without_touching_sys() // producer's bindings-so-far and runs the one infer shape: a single // tool-free round on a fresh conversation that leaves `sys` untouched. let gateway = ScriptedGateway::start(vec![resp_text("h1 answer")]).await; - let source = "---\nname: live-h1-models-infer\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: live-h1-models-infer\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Live H1 Models Infer\n\n\ ```lua\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ var.answer = models.infer('answer once')\n\ var.sys_untouched = not pcall(function() return sys.reply_finish_reason end)\n\ ```\n\n\ @@ -259,11 +204,7 @@ async fn live_h1_models_infer_resolves_the_default_model_without_touching_sys() ```lua\nreturn var.answer .. ':' .. tostring(var.sys_untouched)\n```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let RunResult::Ok(out) = env .run(&prompt, "", to_context(gatewayed(gateway.addr()))) .await @@ -298,22 +239,18 @@ async fn nested_lua_infer_emits_a_model_turn_observation() { // reaches the nested inference path. let gateway = ScriptedGateway::start(vec![resp_text("pong")]).await; let addr = gateway.addr(); - let source = "---\nname: nested-infer-observations\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: nested-infer-observations\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Nested Infer Observations\n\n\ ```lua\n\ - local writer = models.default('writer', 'A general model for tests')\n\ + local writer = models.default('writer')\n\ var.answer = models.infer(writer, 'ping')\n\ ```\n\n\ ## Result\n\n\ ```lua\nreturn var.answer\n```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); let recorder = Arc::new(Recorder::default()); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let RunResult::Ok(out) = env .run( @@ -358,15 +295,14 @@ async fn cancelled_nested_infer_does_not_report_model_turn_failed() { std::time::Duration::from_secs(30), )]) .await; - let source = "---\nname: cancelled-infer\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: cancelled-infer\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Cancelled Infer\n\n\ ```lua\n\ - local writer = models.default('writer', 'A general model for tests')\n\ + local writer = models.default('writer')\n\ return models.infer(writer, 'must cancel')\n\ ```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); let recorder = Arc::new(Recorder::default()); let cancel = crate::cancel::CancelHandle::new(); let canceller = cancel.clone(); @@ -380,16 +316,14 @@ async fn cancelled_nested_infer_does_not_report_model_turn_failed() { .await; canceller.cancel(); }); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let result = env .run( &prompt, "", RunContext::new(EXECUTION) .observer(Arc::clone(&recorder) as Arc) + .model(test_model_catalog().models()[0].clone()) .client(gateway_client(gateway.addr())) .cancel(cancel), ) @@ -416,10 +350,10 @@ async fn cancelled_nested_infer_does_not_report_model_turn_failed() { #[tokio::test(flavor = "multi_thread")] async fn handle_infer_tool_call_violation_uses_entry_point_neutral_wording() { let gateway = ScriptedGateway::start(vec![resp_tool_call("call_1", "ghost", "{}")]).await; - let source = "---\nname: infer-tool-call\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: infer-tool-call\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Infer Tool Call\n\n\ ```lua\n\ - local writer = models.default('writer', 'A general model for tests')\n\ + local writer = models.default('writer')\n\ return models.infer(writer, 'answer without tools')\n\ ```\n"; let error = super::run( @@ -448,10 +382,10 @@ async fn live_h1_prose_infers_explicitly_and_var_accumulates_into_the_walk() { // infer, and `var` writes accumulate across the pass into the walk. let gateway = ScriptedGateway::start(vec![resp_text("final answer")]).await; let addr = gateway.addr(); - let source = "---\nname: live-h1-prose\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: live-h1-prose\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Live H1 Prose\n\n\ ```lua\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ var.executions = (var.executions or 0) + 1\n\ ```\n\n\ Ask for one round.\n\n\ @@ -465,11 +399,7 @@ async fn live_h1_prose_infers_explicitly_and_var_accumulates_into_the_walk() { ```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let RunResult::Ok(out) = env.run(&prompt, "", to_context(gatewayed(addr))).await else { panic!("live H1 prose infers explicitly"); }; @@ -483,10 +413,10 @@ async fn h1_and_h2_prose_each_infer_explicitly_in_source_order() { // The live H1 pass and the H2 section each read their own pending // buffer into an explicit infer: two completions, in source order. let gateway = ScriptedGateway::start(vec![resp_text("h1 reply"), resp_text("h2 reply")]).await; - let source = "---\nname: shared-loop\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: shared-loop\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Shared Loop\n\n\ ```lua\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ ```\n\n\ h1 prose turn\n\n\ ```lua\n\ @@ -499,11 +429,7 @@ async fn h1_and_h2_prose_each_infer_explicitly_in_source_order() { ```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let RunResult::Ok(out) = env .run(&prompt, "", to_context(gatewayed(gateway.addr()))) .await @@ -538,7 +464,7 @@ async fn h1_and_h2_prose_each_infer_explicitly_in_source_order() { async fn live_h1_chunk_keeps_sys_id_zero_and_the_first_walked_section_takes_one() { // The H1 driver holds id 0 off the run-global counter, so the first // walked section takes id 1. - let source = "---\nname: live-h1-sys-id\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: live-h1-sys-id\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Live H1 Sys Id\n\n\ ```lua\n\ assert(sys.id == 0, 'the live H1 chunk keeps sys.id 0')\n\ @@ -550,11 +476,7 @@ async fn live_h1_chunk_keeps_sys_id_zero_and_the_first_walked_section_takes_one( ```\n"; let prompt = parse(source); let picker = empty_test_picker(); - let models = test_model_catalog(); - let env = Environment::new() - .picker(picker) - .models(models) - .tools(ToolCatalog::default()); + let env = Environment::new().picker(picker); let RunResult::Ok(out) = env.run(&prompt, "", to_context(silent())).await else { panic!("the H1 chunk keeps id 0 and the first walked section takes id 1"); }; diff --git a/crates/promptforge-api/src/execute/tests/local_tools.rs b/crates/promptforge-api/src/execute/tests/local_tools.rs index 79f3c980..b2963342 100644 --- a/crates/promptforge-api/src/execute/tests/local_tools.rs +++ b/crates/promptforge-api/src/execute/tests/local_tools.rs @@ -233,10 +233,9 @@ async fn local_tool_alias_cannot_shadow_a_declared_tool() { "Concrete description.", )); let prompt = bound_with_tools( - "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n grab: tests/tools/concrete\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua\n\ -tools.bind('grab', 'capability')\n\ -models.default('writer', 'A general model for tests')\n```\n\n\ +models.default('writer')\n```\n\n\ ## Only\n\n\ ```lua\n\ tools.add_local('grab', 'Local grab', {}, function() return 'local' end)\n\ @@ -254,10 +253,8 @@ tools.add_local('grab', 'Local grab', {}, function() return 'local' end)\n\ .await .expect_err("a local alias must not shadow a declared tool"); assert!( - error - .to_string() - .contains("duplicates a declared tool alias"), - "the error must identify the declared-alias collision: {error}" + error.to_string().contains("duplicates a bound tool slot"), + "the error must identify the bound-slot collision: {error}" ); } diff --git a/crates/promptforge-api/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs index 4a6d91cc..45ea9f9c 100644 --- a/crates/promptforge-api/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -11,9 +11,7 @@ use axum::Router; use axum::extract::State; use axum::http::StatusCode; use axum::routing::post; -use promptforge_tool_picker::{ - Catalog, Config as PickerConfig, ToolDescriptor, ToolId as PickerToolId, ToolPicker, -}; +use promptforge_tool_picker::{Catalog, Config as PickerConfig, ToolDescriptor, ToolPicker}; use serde_json::{Value, json}; use super::gateway::{GatewaySource, env_client_with_limits}; @@ -22,16 +20,19 @@ use super::support::{advance_turn, now_rfc3339_checked}; use super::tool_loop::{LocalDispatch, run_prose_inference}; use super::*; use crate::Result; +use crate::capabilities::CapabilityRegistry; use crate::client::{GatewayClient, GatewayEndpoint, SecretString, ToolSchema}; use crate::debug::DebugCapture; use crate::lua::{LuaProgram, SectionVm, ToolCallCounts, current_tool_bindings}; -use crate::model::{ - CompletionOptions, ModelCatalog, ModelDescriptor, ModelId, ModelSet, ThinkingMode, -}; +use crate::model::{CompletionOptions, ModelDescriptor, ModelId, ModelSet, ThinkingMode}; use crate::observe::{NullObserver, Observation, Observer, detail}; use crate::store::{Access, StoreError, StoreExt, VfsRef}; -use crate::tools::{Tool, ToolCatalog, ToolError, ToolErrorKind, ToolId, ToolOutput}; +use crate::tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; use crate::untrusted::GuardNonce; +use promptforge_model_client::model::ModelCatalog; +use shared_promptforge_api::capabilities::{ + Capability, CapabilityError, CapabilityId, Contribution, RunServices, +}; /// A fresh stock handle's access capability, for tests that inject host /// values into a standalone VM. @@ -60,17 +61,32 @@ const fn _public_execution_types_are_send_sync_static() { assert_send_sync_static::(); assert_send_sync_static::(); assert_send_sync_static::(); - // Borrowing resolution inputs: a fixed concrete lifetime still proves the - // auto traits hold for their owned shape. - assert_send_sync::>(); } /// The runtime's default per-section tool-loop cap, mirrored for tests after the /// `DEFAULT_MAX_TOOL_ITERATIONS` constant was folded into `RunLimits`. const DEFAULT_MAX_TOOL_ITERATIONS: usize = 24; -const MODEL_ALWAYS_SHARED: &str = - "```lua shared\nmodels.default('writer', 'A general model for tests')\n```\n\n"; +/// The `writer` role declaration every model-facing fixture prompt carries: +/// the frontmatter slot, filled by prepare's trivial fill from the +/// context's current model. +const MODEL_ROLE_DECL: &str = "models:\n writer: {}\n"; + +/// The H1 block parking the declared role as the prompt-wide default. +const MODEL_DEFAULT_H1: &str = "```lua\nmodels.default('writer')\n```\n\n"; + +/// Declares the `writer` role in the prompt's frontmatter, unless the +/// frontmatter already declares roles. +fn declare_writer(source: &str) -> String { + let frontmatter_end = source.find("\n---\n").expect("frontmatter closes"); + let frontmatter = &source[..frontmatter_end]; + if frontmatter.contains("\nmodels:") { + return source.to_string(); + } + let mut out = source.to_string(); + out.insert_str(frontmatter_end + 1, MODEL_ROLE_DECL); + out +} /// Lua-only prompts never build the gateway client, so these run offline. fn parse(md: &str) -> Prompt { @@ -118,35 +134,43 @@ fn test_completion_options() -> CompletionOptions { CompletionOptions::new("claude-sonnet-4-6") } +/// Declares the `writer` role and parks it as the prompt-wide default, so a +/// model-facing fixture prompt runs its sections under a bound model. The +/// canonical prose default rewrites to the label form over the declared +/// role; prompts with their own richer `models.bind`/`models.default` +/// shapes keep them (hand-migration cases). fn ensure_model_h1(md: &str) -> String { - let first_section = md.find("\n\n## "); let mut source = md.to_string(); if source.contains("models.default") || source.contains("models.bind") { - source = source - .replace("```lua shared\nmodels.", "```lua\nmodels.") - .replace("```lua shared\n models.", "```lua\n models."); - return source; + // The canonical prose default becomes the label form over a + // declared role; richer bind shapes are hand-migration cases. + source = source.replace( + "models.default('writer', 'A general model for tests')", + "models.default('writer')", + ); + return declare_writer(&source); } + let source = declare_writer(&source); + // Positions come from the post-declaration text: the declaration + // insertion shifts every later index. + let first_section = source.find("\n\n## "); + let mut source = source; if let Some(marker) = source.find("```lua\n") && first_section.is_none_or(|section| marker < section) { source.replace_range(marker..marker + "```lua".len(), "```lua shared"); if let Some(pos) = source.find("\n\n## ") { - source.insert_str(pos + 2, &MODEL_ALWAYS_SHARED.replace("lua shared", "lua")); + source.insert_str(pos + 2, MODEL_DEFAULT_H1); } return source; } if let Some(pos) = first_section { - let mut out = source; - out.insert_str(pos + 2, &MODEL_ALWAYS_SHARED.replace("lua shared", "lua")); - return out; + source.insert_str(pos + 2, MODEL_DEFAULT_H1); + return source; } source.replacen( "---\n\n", - &format!( - "---\n\n# Test prompt\n\n{}", - MODEL_ALWAYS_SHARED.replace("lua shared", "lua") - ), + &format!("---\n\n# Test prompt\n\n{MODEL_DEFAULT_H1}"), 1, ) } @@ -263,10 +287,13 @@ impl TestStore { } /// Builds a [`RunContext`] from the test-local [`RunOptions`], for the tests -/// that call [`Environment::run`] directly with a custom picker and model -/// catalog. +/// that call [`Environment::run`] directly. The context carries the test +/// model as the current selection, so prepare's trivial fill binds every +/// declared role to it. fn to_context(opts: RunOptions) -> RunContext { - let mut ctx = RunContext::new(opts.execution).observer(opts.observer); + let mut ctx = RunContext::new(opts.execution) + .observer(opts.observer) + .model(test_model_catalog().models()[0].clone()); if let Some(client) = opts.client { ctx = ctx.client(client); } @@ -351,12 +378,18 @@ async fn run( .and_then(|config| config.with_margin(0.0)) .expect("test thresholds are in the supported domain"); let picker = build_test_picker(catalog, config); - let tool_catalog = ToolCatalog::new(tools).expect("fixture tools are unique"); - let env = Environment::new() - .picker(picker) - .models(test.models.clone()) - .tools(tool_catalog); + let mut env = Environment::new().picker(picker); + if !tools.is_empty() { + // The fixture capability contributes the test's tools, so the + // prompt's declared slots fill against them at prepare. + env = env.registry(tools_registry(tools)); + } let mut ctx = RunContext::new(opts.execution).observer(opts.observer); + // The host pattern: the context carries the current model, and + // prepare's trivial fill binds every declared role to it. + if let Some(model) = test.models.models().first() { + ctx = ctx.model(model.clone()); + } if let Some(client) = opts.client { ctx = ctx.client(client); } @@ -410,6 +443,47 @@ pub(super) fn shared_test_model() -> &'static promptforge_tool_picker::Model { MODEL.get_or_init(|| promptforge_tool_picker::Model::load().expect("the test model loads")) } +/// The fixture capability: contributes the test's tools under +/// `tests/tools`, so a prompt's frontmatter tool slots fill against them +/// at prepare - the shape production tools arrive in. +struct FixtureCapability { + id: CapabilityId, + tools: Vec>, +} + +impl Capability for FixtureCapability { + fn id(&self) -> &CapabilityId { + &self.id + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Capability trait fixes this return type to &str" + )] + fn description(&self) -> &str { + "The fixture tool capability." + } + + fn create(&self, services: &RunServices) -> std::result::Result { + let _ = services; + Ok(Contribution { + tools: self.tools.clone(), + }) + } +} + +/// Builds the registry holding one fixture capability contributing `tools`. +fn tools_registry(tools: &[Arc]) -> CapabilityRegistry { + let mut registry = CapabilityRegistry::new(); + registry + .register(Arc::new(FixtureCapability { + id: CapabilityId::from_validated("tests/tools"), + tools: tools.to_vec(), + })) + .expect("the fixture capability registers"); + registry +} + /// Runs a fixture offline through the real [`Environment::run`] entry point /// with a caller-customized [`RunContext`], returning the typed [`RunError`] /// so a test can assert on its kind (limits, cancellation). @@ -417,10 +491,13 @@ async fn run_with_context( test: &TestPrompt, configure: impl FnOnce(RunContext) -> RunContext, ) -> std::result::Result { - let env = Environment::new() - .picker(empty_test_picker()) - .models(test.models.clone()); - let ctx = configure(RunContext::new(EXECUTION)).vfs(TestStore::new().vfs()); + let env = Environment::new().picker(empty_test_picker()); + let mut ctx = configure(RunContext::new(EXECUTION)).vfs(TestStore::new().vfs()); + if ctx.model.is_none() + && let Some(model) = test.models.models().first() + { + ctx = ctx.model(model.clone()); + } match env.run(&test.prompt, "", ctx).await { RunResult::Ok(output) => Ok(output), RunResult::Cancelled => Err(RunError::from(Error::Interrupted)), @@ -1160,8 +1237,8 @@ fn tool_description_override_appears_in_model_schema() { ); let mut vm = SectionVm::new_for_section( &GuardNonce::fresh(), - &bindings, - &ModelSet::default(), + &Arc::new(Mutex::new(bindings)), + &Arc::new(Mutex::new(ModelSet::default())), EXECUTION, &NullObserver::default(), "Override", @@ -1184,7 +1261,7 @@ fn tool_description_override_appears_in_model_schema() { .expect("prologue must compile"); vm.run_chunk(&add_default, &NullObserver::default(), "Override") .expect("tools.add(echo) without override must succeed"); - let (tool_bindings, tool_runtime) = vm.tool_bag_handles(); + let (tool_bindings, tool_runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&tool_bindings, &tool_runtime).expect("tool scope must snapshot"); let (schemas, _) = prepare_scoped_tools(&scope, &[]).expect("schemas must build"); @@ -1234,8 +1311,8 @@ fn bind_override_reaches_the_schema_and_add_beats_bind() { ); let mut vm = SectionVm::new_for_section( &GuardNonce::fresh(), - &bindings, - &ModelSet::default(), + &Arc::new(Mutex::new(bindings)), + &Arc::new(Mutex::new(ModelSet::default())), EXECUTION, &NullObserver::default(), "Precedence", @@ -1257,7 +1334,7 @@ fn bind_override_reaches_the_schema_and_add_beats_bind() { .expect("prologue must compile"); vm.run_chunk(&add_plain, &NullObserver::default(), "Precedence") .expect("tools.add without override must succeed"); - let (tool_bindings, tool_runtime) = vm.tool_bag_handles(); + let (tool_bindings, tool_runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&tool_bindings, &tool_runtime).expect("tool scope must snapshot"); let (schemas, _) = prepare_scoped_tools(&scope, &[]).expect("schemas must build"); @@ -1751,10 +1828,9 @@ async fn untrusted_nonce_differs_across_runs() { // The nonce is minted once per run: two runs of the same prompt wrap the // same untrusted tool result under different nonces, so an envelope's tag // stays unguessable from one run to the next. - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/untrusted_echo\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ - tools.bind('echo', 'echo tool')\n\ - models.default('writer', 'A general model for tests')\n```\n\n\ + models.default('writer')\n```\n\n\ ## Only\n\n\ ```lua\nreturn tools.call('echo', { value = 'hi' })\n```\n"; let mut run_nonces = Vec::new(); @@ -1840,33 +1916,6 @@ const STORE_SECTIONS: &str = "---\nname: t\ndescription: d\npromptforge: 0\n---\ ## First\n\n```lua\nstore.write('state.txt', 'first')\n```\n\n\ ## Second\n\n```lua\nstore.append('state.txt', '\\nsecond')\nreturn \"second\"\n```\n"; -/// The picker's calibrated enriched text for a descriptor. -/// -/// The engine's own derivation is crate-private, so this test mirror lets a -/// need equal a tool's embedded text and bind it under any threshold. -fn capability_for(descriptor: &ToolDescriptor) -> String { - let mut parts: Vec = Vec::new(); - let name = descriptor.name().replace('_', " "); - if !name.is_empty() { - parts.push(name); - } - if !descriptor.description().is_empty() { - parts.push(descriptor.description().to_owned()); - } - let mut params: Vec<&str> = descriptor - .input_schema() - .as_object() - .and_then(|schema| schema.get("properties")) - .and_then(serde_json::Value::as_object) - .map(|properties| properties.keys().map(String::as_str).collect()) - .unwrap_or_default(); - params.sort_unstable(); - if !params.is_empty() { - parts.push(format!("parameters: {}", params.join(", "))); - } - parts.join(". ") -} - /// Records every [`DebugEvent`] so tests can assert capture wiring. #[derive(Default)] struct RecordingCapture(Mutex>); diff --git a/crates/promptforge-api/src/execute/tests/model_and_reply.rs b/crates/promptforge-api/src/execute/tests/model_and_reply.rs index 66b856e7..52d73939 100644 --- a/crates/promptforge-api/src/execute/tests/model_and_reply.rs +++ b/crates/promptforge-api/src/execute/tests/model_and_reply.rs @@ -5,44 +5,39 @@ use super::*; #[tokio::test] async fn models_use_forwards_binding_completion_options_to_the_gateway() { // models.use -> completion_options -> GatewayClient::complete must carry - // the binding's model and sampling fields on the chat body. + // the binding's model and the hard-keyword thinking switch on the chat + // body. (v1 roles declare no sampling fields; the thinking switch is the + // one invocation parameter with a frontmatter source.) let gateway = ScriptedGateway::start(vec![resp_text("hello from the mock")]).await; let addr = gateway.addr(); - let catalog = ModelCatalog::new([ModelDescriptor::new( - ModelId::gateway("analyst").expect("the test model alias is valid"), - "A careful analysis model", - NonZeroU32::new(131_072).expect("131072 is non-zero"), - ThinkingMode::Switchable, - )]) - .expect("the test catalog has a single unique model"); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n analyst:\n keywords: [no-thinking]\n---\n\n\ # T\n\n\ -```lua\n\ -models.bind('analyst', 'careful analysis', { temperature = 0.25, max_tokens = 64, thinking = false })\n\ -```\n\n\ ## Only\n\n\ ```lua\nmodels.use('analyst')\n```\n\n\ Ask the model.\n\n\ ```lua\nreturn models.infer(prose)\n```\n"; let prompt = Prompt::parse(md, EXECUTION, &NullObserver::default()).expect("fixture must parse"); - let prompt = TestPrompt { - prompt, - models: catalog, - picker_catalog: None, + let mut ctx = RunContext::new(EXECUTION).client(gateway_client(addr)); + ctx.model_bindings.bind( + "analyst", + ModelDescriptor::new( + ModelId::gateway("analyst").expect("the test model alias is valid"), + "A careful analysis model", + NonZeroU32::new(131_072).expect("131072 is non-zero"), + ThinkingMode::Switchable, + ), + ); + let out = match crate::execute::run(&prompt, "", ctx).await { + RunResult::Ok(out) => out, + other => panic!("the run must succeed: {other:?}"), }; - - let out = run(&prompt, "", &[], &TestStore::new(), gatewayed(addr)) - .await - .unwrap(); assert_eq!(out, "hello from the mock"); let body = gateway .last_request() .expect("complete must reach the gateway"); assert_eq!(body["model"], "analyst"); - assert_eq!(body["temperature"], 0.25); - assert_eq!(body["max_tokens"], 64); assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false); } @@ -76,6 +71,14 @@ async fn an_explicit_client_is_used_instead_of_the_environment() { recorder.events(), vec![ ("Test prompt".to_string(), detail::RUN_STARTED.to_string()), + ( + "Test prompt".to_string(), + detail::LUA_SHARED_LOAD_STARTED.to_string(), + ), + ( + "Test prompt".to_string(), + detail::LUA_SHARED_LOAD_SUCCEEDED.to_string(), + ), ( "Test prompt".to_string(), detail::LUA_CHUNK_STARTED.to_string(), @@ -154,6 +157,14 @@ return 'epilog result'\n\ recorder.events(), vec![ ("Test prompt".to_string(), detail::RUN_STARTED.to_string()), + ( + "Test prompt".to_string(), + detail::LUA_SHARED_LOAD_STARTED.to_string(), + ), + ( + "Test prompt".to_string(), + detail::LUA_SHARED_LOAD_SUCCEEDED.to_string(), + ), ( "Test prompt".to_string(), detail::LUA_CHUNK_STARTED.to_string(), @@ -209,8 +220,8 @@ async fn add_without_h1_bindings_fails_the_run_loudly() { .await .expect_err("an undeclared alias must fail the run"); assert!( - error.to_string().contains("not declared by tools.bind"), - "the error must report the missing declaration: {error}" + error.to_string().contains("is not a bound tool slot"), + "the error must report the missing slot: {error}" ); } @@ -226,8 +237,8 @@ async fn add_with_an_empty_shared_library_fails_the_run_loudly() { .await .expect_err("an undeclared alias must fail the run"); assert!( - error.to_string().contains("not declared by tools.bind"), - "the error must report the missing declaration: {error}" + error.to_string().contains("is not a bound tool slot"), + "the error must report the missing slot: {error}" ); } @@ -276,6 +287,14 @@ Ask using {{ var.question }}.\n\n\ recorder.events(), [ ("Test prompt".to_owned(), detail::RUN_STARTED.to_string()), + ( + "Test prompt".to_owned(), + detail::LUA_SHARED_LOAD_STARTED.to_string(), + ), + ( + "Test prompt".to_owned(), + detail::LUA_SHARED_LOAD_SUCCEEDED.to_string(), + ), ( "Test prompt".to_owned(), detail::LUA_CHUNK_STARTED.to_string(), @@ -401,10 +420,9 @@ async fn prose_substitution_sees_sys_model_catalog_id() { // The first script dispatch runs the one-time scope install, which // enriches `sys.model` with the bound catalog id; a prose read after it // substitutes the catalog id, not the alias. - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/echo\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ -tools.bind('echo', 'echo tool')\n\ -models.default('writer', 'A general model for tests')\n```\n\n\ +models.default('writer')\n```\n\n\ ## Only\n\n```lua\ntools.call('echo', { value = 'x' })\n```\n\nModel id is {{ sys.model }}.\n\n\ ```lua\nreturn prose\n```\n"; let prompt = bound_with_tools(md, Vec::new()); @@ -422,10 +440,9 @@ models.default('writer', 'A general model for tests')\n```\n\n\ #[tokio::test] async fn epilog_sees_model_catalog_id_not_alias_after_the_scope_install() { - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/echo\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ -tools.bind('echo', 'echo tool')\n\ -models.default('writer', 'A general model for tests')\n```\n\n\ +models.default('writer')\n```\n\n\ ## Only\n\n```lua\ntools.call('echo', { value = 'x' })\n```\n\n```lua\nreturn sys.model\n```\n"; let prompt = bound_with_tools(md, Vec::new()); let out = run( @@ -442,10 +459,9 @@ models.default('writer', 'A general model for tests')\n```\n\n\ #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fanout_arm_sees_sys_model_catalog_id_after_the_scope_install() { - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/echo\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ -tools.bind('echo', 'echo tool')\n\ -models.default('writer', 'A general model for tests')\n```\n\n\ +models.default('writer')\n```\n\n\ ## Parent\n\n```lua\nlocal r = fanout('### Worker', list_from_section('### Items'))\nreturn table.concat(r, ',')\n```\n\n\ ### Worker\n\n```lua\ntools.call('echo', { value = item })\n```\n\n\ ```lua\nreturn sys.model .. ':' .. item\n```\n\n\ @@ -525,37 +541,6 @@ async fn reply_substitution_is_an_unknown_global_error() { // --- models.get / models.infer with a leading handle --- -/// A two-model catalog: `writer` resolves to `writer-model`, `analyst` to -/// `analyst-model`, so a test can tell which model a request used. -fn writer_and_analyst_catalog() -> ModelCatalog { - let context = NonZeroU32::new(131_072).expect("131072 is non-zero"); - ModelCatalog::new([ - ModelDescriptor::new( - ModelId::gateway("writer-model").expect("the writer model id is valid"), - "A general model for tests", - context, - ThinkingMode::Switchable, - ), - ModelDescriptor::new( - ModelId::gateway("analyst-model").expect("the analyst model id is valid"), - "A careful analysis model", - context, - ThinkingMode::Switchable, - ), - ]) - .expect("the test catalog has two unique models") -} - -fn analyst_only_catalog() -> ModelCatalog { - ModelCatalog::new([ModelDescriptor::new( - ModelId::gateway("analyst-model").expect("the analyst model id is valid"), - "A careful analysis model", - NonZeroU32::new(131_072).expect("131072 is non-zero"), - ThinkingMode::Switchable, - )]) - .expect("the test catalog has a single unique model") -} - /// Run a parsed prompt against a scripted gateway with no external tools. async fn run_with_gateway( test: &TestPrompt, @@ -565,27 +550,61 @@ async fn run_with_gateway( run(test, "", &[], store, gatewayed(addr)).await } +/// Runs a prompt with hand-filled model bindings and no prepare pass: the +/// multi-model shape v1's trivial fill cannot produce (every role bound to +/// the one current model), exercising the runtime's label resolution +/// directly. `bindings` pairs a declared role label with the gateway model +/// id it resolves to. +async fn run_with_bindings( + md: &str, + bindings: &[(&str, &str)], + addr: SocketAddr, + store: &TestStore, +) -> Result { + let prompt = parse(md); + let mut ctx = RunContext::new(EXECUTION) + .client(gateway_client(addr)) + .vfs(store.vfs()); + for (label, model) in bindings { + ctx.model_bindings.bind( + label, + ModelDescriptor::new( + ModelId::gateway(*model).expect("the test model id is valid"), + "A test model", + NonZeroU32::new(131_072).expect("131072 is non-zero"), + ThinkingMode::Switchable, + ), + ); + } + match crate::execute::run(&prompt, "", ctx).await { + RunResult::Ok(out) => Ok(out), + RunResult::Cancelled => Err(Error::Interrupted), + RunResult::Failure(error) => Err(Error::from(error)), + } +} + #[tokio::test] async fn models_get_returns_a_handle_without_changing_the_section_model() { let gateway = ScriptedGateway::start(vec![resp_text("hello from the mock")]).await; let addr = gateway.addr(); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n analyst: {}\n---\n\n\ # T\n\n\ ```lua\n\ -models.default('writer', 'A general model for tests')\n\ -models.bind('analyst', 'A careful analysis model')\n\ +models.default('writer')\n\ ```\n\n\ ## Only\n\n\ ```lua\nstore.write('handle.txt', models.get('analyst').name)\n```\n\n\ Ask the model.\n\n\ ```lua\nreturn models.infer(prose)\n```\n"; - let prompt = TestPrompt { - prompt: parse(md), - models: writer_and_analyst_catalog(), - picker_catalog: None, - }; let store = TestStore::new(); - let out = run_with_gateway(&prompt, addr, &store).await.unwrap(); + let out = run_with_bindings( + md, + &[("writer", "writer-model"), ("analyst", "analyst-model")], + addr, + &store, + ) + .await + .unwrap(); assert_eq!(out, "hello from the mock"); assert_eq!( @@ -637,22 +656,21 @@ async fn models_infer_uses_the_section_model_without_touching_reply() { async fn handle_infer_uses_that_model_regardless_of_the_section_model() { let gateway = ScriptedGateway::start(vec![resp_text("pong")]).await; let addr = gateway.addr(); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n analyst: {}\n---\n\n\ # T\n\n\ ```lua\n\ -models.default('writer', 'A general model for tests')\n\ -models.bind('analyst', 'A careful analysis model')\n\ +models.default('writer')\n\ ```\n\n\ ## Only\n\n\ ```lua\nreturn models.infer(models.get('analyst'), 'ping')\n```\n"; - let prompt = TestPrompt { - prompt: parse(md), - models: writer_and_analyst_catalog(), - picker_catalog: None, - }; - let out = run_with_gateway(&prompt, addr, &TestStore::new()) - .await - .unwrap(); + let out = run_with_bindings( + md, + &[("writer", "writer-model"), ("analyst", "analyst-model")], + addr, + &TestStore::new(), + ) + .await + .unwrap(); assert_eq!(out, "pong"); let body = gateway .last_request() @@ -667,11 +685,10 @@ models.bind('analyst', 'A careful analysis model')\n\ async fn models_use_reselection_steers_the_next_round() { let gateway = ScriptedGateway::start(vec![resp_text("first"), resp_text("second")]).await; let addr = gateway.addr(); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n analyst: {}\n---\n\n\ # T\n\n\ ```lua\n\ -models.default('writer', 'A general model for tests')\n\ -models.bind('analyst', 'A careful analysis model')\n\ +models.default('writer')\n\ ```\n\n\ ## Only\n\n\ ```lua\n\ @@ -680,14 +697,14 @@ models.infer('ping')\n\ models.use('analyst')\n\ return models.infer('ping')\n\ ```\n"; - let prompt = TestPrompt { - prompt: parse(md), - models: writer_and_analyst_catalog(), - picker_catalog: None, - }; - let out = run_with_gateway(&prompt, addr, &TestStore::new()) - .await - .expect("re-selection within a section must succeed"); + let out = run_with_bindings( + md, + &[("writer", "writer-model"), ("analyst", "analyst-model")], + addr, + &TestStore::new(), + ) + .await + .expect("re-selection within a section must succeed"); assert_eq!(out, "second"); let requests = gateway.requests(); assert_eq!( @@ -707,19 +724,25 @@ return models.infer('ping')\n\ #[tokio::test] async fn models_infer_without_use_or_default_errors() { - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n analyst: {}\n---\n\n\ # T\n\n\ -```lua\nmodels.bind('analyst', 'A careful analysis model')\n```\n\n\ ## Only\n\n\ ```lua\nreturn models.infer('ping')\n```\n"; - let prompt = TestPrompt { - prompt: parse(md), - models: analyst_only_catalog(), - picker_catalog: None, + let prompt = parse(md); + let mut ctx = RunContext::new(EXECUTION); + ctx.model_bindings.bind( + "analyst", + ModelDescriptor::new( + ModelId::gateway("analyst-model").expect("the analyst model id is valid"), + "A careful analysis model", + NonZeroU32::new(131_072).expect("131072 is non-zero"), + ThinkingMode::Switchable, + ), + ); + let error = match crate::execute::run(&prompt, "", ctx).await { + RunResult::Failure(error) => error, + other => panic!("models.infer with no current model must fail: {other:?}"), }; - let error = run(&prompt, "", &[], &TestStore::new(), silent()) - .await - .expect_err("models.infer with no current model must fail"); assert!( error .to_string() @@ -732,17 +755,11 @@ async fn models_infer_without_use_or_default_errors() { async fn models_get_infer_works_without_any_section_model() { let gateway = ScriptedGateway::start(vec![resp_text("pong")]).await; let addr = gateway.addr(); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n analyst: {}\n---\n\n\ # T\n\n\ -```lua\nmodels.bind('analyst', 'A careful analysis model')\n```\n\n\ ## Only\n\n\ ```lua\nreturn models.infer(models.get('analyst'), 'ping')\n```\n"; - let prompt = TestPrompt { - prompt: parse(md), - models: analyst_only_catalog(), - picker_catalog: None, - }; - let out = run_with_gateway(&prompt, addr, &TestStore::new()) + let out = run_with_bindings(md, &[("analyst", "analyst-model")], addr, &TestStore::new()) .await .unwrap(); assert_eq!(out, "pong"); diff --git a/crates/promptforge-api/src/execute/tests/observations.rs b/crates/promptforge-api/src/execute/tests/observations.rs index 28db0b2d..57b0d175 100644 --- a/crates/promptforge-api/src/execute/tests/observations.rs +++ b/crates/promptforge-api/src/execute/tests/observations.rs @@ -18,14 +18,6 @@ async fn a_two_section_run_reports_the_exact_observation_sequence() { events(&records), vec![ ("Test prompt".to_string(), detail::RUN_STARTED.to_string()), - ( - "Test prompt".to_string(), - detail::LUA_TEARDOWN_STARTED.to_string(), - ), - ( - "Test prompt".to_string(), - detail::LUA_TEARDOWN_SUCCEEDED.to_string(), - ), ("First".to_string(), detail::SECTION_STARTED.to_string()), ( "First".to_string(), @@ -161,14 +153,6 @@ async fn a_failing_run_still_reports_run_finished() { events(&records), vec![ ("Test prompt".to_string(), detail::RUN_STARTED.to_string()), - ( - "Test prompt".to_string(), - detail::LUA_TEARDOWN_STARTED.to_string(), - ), - ( - "Test prompt".to_string(), - detail::LUA_TEARDOWN_SUCCEEDED.to_string(), - ), ("Only".to_string(), detail::SECTION_STARTED.to_string()), ( "Only".to_string(), @@ -243,8 +227,8 @@ async fn an_erroring_section_tears_down_exactly_once_without_finishing() { #[tokio::test] async fn a_one_byte_limit_fails_host_injection_with_teardown_observations() { // mlua accepts the one-byte ceiling itself, then the first host allocation - // fails. Host injection is inside the H1 teardown boundary, unlike the - // preceding bare apply_lua_limits call. + // fails. Host injection is inside the section's teardown boundary, unlike + // the preceding bare apply_lua_limits call. let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ ## Only\n\n```lua\nreturn \"ran\"\n```\n"; let recorder = Arc::new(Recorder::default()); @@ -262,13 +246,11 @@ async fn a_one_byte_limit_fails_host_injection_with_teardown_observations() { let observed = events(&recorder.records()); assert!( - observed.contains(&( - "Test prompt".to_owned(), - detail::LUA_TEARDOWN_STARTED.to_string() - )) && observed.contains(&( - "Test prompt".to_owned(), - detail::LUA_TEARDOWN_SUCCEEDED.to_string() - )), + observed.contains(&("Only".to_owned(), detail::LUA_TEARDOWN_STARTED.to_string())) + && observed.contains(&( + "Only".to_owned(), + detail::LUA_TEARDOWN_SUCCEEDED.to_string() + )), "host injection failure must fire both teardown observations: {observed:?}" ); } @@ -282,36 +264,26 @@ async fn one_execution_id_spans_parse_and_the_complete_runtime_lifecycle() { "canonical_echo", "Echo a test value.", )); - let descriptor = ToolDescriptor::new( - PickerToolId::parse("tests/tools/echo").expect("fixture id is valid"), - tool.description(), - tool.parameters_schema(), - ); - let capability = - serde_json::to_string(&capability_for(&descriptor)).expect("serialize fixture capability"); - let source = format!( - "---\nname: lifecycle\ndescription: Correlated lifecycle fixture\npromptforge: 0\n---\n\n\ + let source = "---\nname: lifecycle\ndescription: Correlated lifecycle fixture\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/echo\nmodels:\n writer: {}\n---\n\n\ # Lifecycle\n\n```lua\n\ - tools.bind('echo', {capability})\n\ tools.always('echo')\n\ - models.default('writer', 'A general model for tests')\n```\n\n\ + models.default('writer')\n```\n\n\ ## Gather\n\n```lua\nstore.write('state.txt', 'before')\n```\n\n\ Use the echo tool.\n\n\ ```lua\n\ local text = models.infer(prose)\n\ - local _ = tools.call('echo', {{ value = 'hi' }})\n\ + local _ = tools.call('echo', { value = 'hi' })\n\ store.append('state.txt', '\\nafter')\n\ return text\n\ - ```\n" - ); + ```\n"; let recorder = Arc::new(Recorder::default()); - let prompt = Prompt::parse(&source, EXECUTION, recorder.as_ref()) + let prompt = Prompt::parse(source, EXECUTION, recorder.as_ref()) .expect("the lifecycle fixture must parse"); let tools: [Arc; 1] = [Arc::clone(&tool) as Arc]; let prompt = TestPrompt { prompt, models: test_model_catalog(), - picker_catalog: Some(Catalog::new(vec![descriptor])), + picker_catalog: None, }; let store = TestStore::new(); diff --git a/crates/promptforge-api/src/execute/tests/scheduler.rs b/crates/promptforge-api/src/execute/tests/scheduler.rs index 03a24022..ff3af1f2 100644 --- a/crates/promptforge-api/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api/src/execute/tests/scheduler.rs @@ -1252,72 +1252,50 @@ fn h1_context(prompt: &Prompt) -> RunState { /// Builds the H1 run context on the given store and observer, so a pass /// test can inspect the store's contents and the observation stream -/// afterward. +/// afterward. The context's model bindings are filled the way prepare's +/// trivial fill does: every declared role bound to the test model. fn h1_context_on(prompt: &Prompt, store: &TestStore, observer: Arc) -> RunState { + let mut ctx = RunContext::new(EXECUTION).observer(observer); + for (label, _) in prompt.frontmatter().models().iter() { + ctx.model_bindings.bind( + label, + ModelDescriptor::new( + ModelId::gateway("claude-sonnet-4-6").expect("the test model id is valid"), + "A general model for tests", + NonZeroU32::new(131_072).expect("131072 is non-zero"), + ThinkingMode::Switchable, + ), + ); + } RunState::new( prompt, "", &store.vfs(), LuaProgram::empty().expect("the empty chunk compiles"), - &RunContext::new(EXECUTION).observer(observer), + &ctx, ) } -/// The live H1 resolution inputs for a scheduler test, bundled so the -/// borrows outlive the drive. -struct H1Resolution { - picker: ToolPicker, - models: ModelCatalog, - tools: ToolCatalog, -} - -impl H1Resolution { - /// An empty picker and tool catalog with the test model catalog: H1 - /// model binds resolve, tool binds report absent. - fn models_only() -> Self { - Self { - picker: empty_test_picker(), - models: test_model_catalog(), - tools: ToolCatalog::default(), - } - } - - /// Everything empty: model binds report absent. - fn empty() -> Self { - Self { - picker: empty_test_picker(), - models: ModelCatalog::empty(), - tools: ToolCatalog::default(), - } - } - - fn context(&self) -> ResolutionContext<'_> { - ResolutionContext::new(Some(&self.picker), &self.models, &self.tools) - } -} - #[tokio::test(flavor = "current_thread")] async fn live_h1_infer_runs_once() { - // Mirror of the legacy case of the same name: the H1 pass binds the - // default model, a handle's `infer` yields through the shim, and the - // H1 `var` hand-off seeds the walk. + // Mirror of the legacy case of the same name: the H1 pass selects the + // default model by label, a handle's `infer` yields through the shim, + // and the H1 `var` hand-off seeds the walk. let gateway = ScriptedGateway::start(vec![resp_text("h1 answer")]).await; - let md = "---\nname: live-h1\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: live-h1\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Live H1\n\n\ ```lua\n\ - local writer = models.default('writer', 'A general model for tests')\n\ + local writer = models.default('writer')\n\ var.answer = models.infer(writer, 'answer once')\n\ ```\n\n\ ## Result\n\n\ ```lua\nreturn var.answer\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::models_only(); let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) - .with_live_h1(resolution.context()) .drive() .await - .expect("live H1 path must run on the scheduler"); + .expect("the H1 pass must run on the scheduler"); assert_eq!(out, "h1 answer"); assert_eq!(gateway.call_count(), 1); @@ -1325,15 +1303,15 @@ async fn live_h1_infer_runs_once() { #[tokio::test(flavor = "current_thread")] async fn live_h1_models_infer_resolves_the_default_model_without_touching_sys() { - // Mirror of the legacy case of the same name: the live H1 + // Mirror of the legacy case of the same name: the H1 // `models.infer` (no handle) resolves the current model from the - // bindings-so-far and runs the one infer shape - a single tool-free + // shared set and runs the one infer shape - a single tool-free // round on a fresh conversation that leaves `sys` untouched. let gateway = ScriptedGateway::start(vec![resp_text("h1 answer")]).await; - let md = "---\nname: live-h1-models-infer\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: live-h1-models-infer\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Live H1 Models Infer\n\n\ ```lua\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ var.answer = models.infer('answer once')\n\ var.sys_untouched = not pcall(function() return sys.reply_finish_reason end)\n\ ```\n\n\ @@ -1341,12 +1319,10 @@ async fn live_h1_models_infer_resolves_the_default_model_without_touching_sys() ```lua\nreturn var.answer .. ':' .. tostring(var.sys_untouched)\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::models_only(); let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) - .with_live_h1(resolution.context()) .drive() .await - .expect("live H1 models.infer must run on the scheduler"); + .expect("H1 models.infer must run on the scheduler"); assert_eq!(out, "h1 answer:true"); assert_eq!(gateway.call_count(), 1); @@ -1375,7 +1351,7 @@ async fn live_h1_chunk_keeps_sys_id_zero_and_the_first_walked_section_takes_one( let md = "---\nname: live-h1-sys-id\ndescription: d\npromptforge: 0\n---\n\n\ # Live H1 Sys Id\n\n\ ```lua\n\ - assert(sys.id == 0, 'the live H1 chunk keeps sys.id 0')\n\ + assert(sys.id == 0, 'the H1 chunk keeps sys.id 0')\n\ ```\n\n\ ## Result\n\n\ ```lua\n\ @@ -1384,9 +1360,7 @@ async fn live_h1_chunk_keeps_sys_id_zero_and_the_first_walked_section_takes_one( ```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let out = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect("the H1 chunk keeps id 0 and the first walked section takes id 1"); @@ -1395,15 +1369,14 @@ async fn live_h1_chunk_keeps_sys_id_zero_and_the_first_walked_section_takes_one( } #[tokio::test(flavor = "current_thread")] -async fn caught_h1_callback_error_stops_before_a_later_block() { - // Mirror of the legacy case of the same name: a pcall'd resolver - // failure is caught by the chunk but recorded by the callback, and the - // recorded typed error fails the run before the next H1 block runs. - let md = "---\nname: callback-drain\ndescription: d\npromptforge: 0\n---\n\n\ - # Callback Drain\n\n\ +async fn a_failed_h1_assertion_ends_the_run_as_requirements_unmet() { + // H1's remaining job is the prompt's hard gates: a failed `assert` is + // the failed assertion, ending the run before the walk with the + // RequirementsUnmet classification and the failure notice as content. + let md = "---\nname: h1-gate\ndescription: d\npromptforge: 0\n---\n\n\ + # Gate\n\n\ ```lua\n\ - local ok = pcall(models.bind, 'missing', 'unavailable model')\n\ - assert(not ok)\n\ + assert(false, 'the gate cannot hold')\n\ ```\n\n\ ```lua\nstore.write('later.txt', 'ran')\n```\n\n\ ## Result\n\n\ @@ -1411,68 +1384,58 @@ async fn caught_h1_callback_error_stops_before_a_later_block() { let prompt = parse(md); let store = TestStore::new(); let ctx = h1_context_on(&prompt, &store, Arc::new(NullObserver::default())); - let resolution = H1Resolution::empty(); let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await - .expect_err("a caught resolver callback error must fail its own block"); + .expect_err("a failed H1 assertion must fail the run"); assert!( - matches!(error, Error::ModelAbsent { .. }), - "the current block's typed callback error must surface: {error}" + matches!(error, Error::RequirementsUnmet { .. }), + "the failed assertion classifies as RequirementsUnmet: {error}" + ); + assert!( + error.to_string().contains("the gate cannot hold"), + "the notice carries the assertion's message: {error}" ); assert!( store.read("later.txt").is_err(), - "the later H1 block must not run after the callback error" + "the later H1 block must not run after the failed gate" ); } #[tokio::test(flavor = "current_thread")] -async fn a_caught_h1_callback_error_reports_the_chunk_succeeded() { - // The observation boundary of the callback-error rule: the chunk - // caught the resolver's Lua error itself and ran to completion, so it - // reports LUA_CHUNK_SUCCEEDED; the recorded typed error fails the run - // only afterward - the legacy `run_live_h1_block` mapping, where the - // callback check follows the chunk's own boundary. +async fn an_uncaught_h1_assertion_reports_the_chunk_failed() { + // The observation boundary of the H1 gate rule: an uncaught assertion + // failure is the chunk's own failure, so the chunk reports + // LUA_CHUNK_FAILED and the run ends as RequirementsUnmet. let recorder = Arc::new(Recorder::default()); let md = "---\nname: callback-drain\ndescription: d\npromptforge: 0\n---\n\n\ # Callback Drain\n\n\ ```lua\n\ - local ok = pcall(models.bind, 'missing', 'unavailable model')\n\ - assert(not ok)\n\ + assert(false, 'the gate cannot hold')\n\ ```\n"; let prompt = parse(md); let ctx = h1_context_on(&prompt, &TestStore::new(), recorder.clone()); - let resolution = H1Resolution::empty(); let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await - .expect_err("the recorded callback error must fail the run"); + .expect_err("the failed gate must fail the run"); assert!( - matches!(error, Error::ModelAbsent { .. }), - "the typed callback error must surface: {error}" + matches!(error, Error::RequirementsUnmet { .. }), + "the failed gate classifies as RequirementsUnmet: {error}" ); let observed = recorder.events(); let title = "Callback Drain".to_string(); assert!( - observed.contains(&(title.clone(), detail::LUA_CHUNK_SUCCEEDED.to_string())), - "the chunk that caught the error reports succeeded: {observed:?}" - ); - assert!( - !observed - .iter() - .any(|(section, event)| section == &title - && event == &detail::LUA_CHUNK_FAILED.to_string()), - "the chunk must not report failed: {observed:?}" + observed.contains(&(title.clone(), detail::LUA_CHUNK_FAILED.to_string())), + "the chunk with the failed gate reports failed: {observed:?}" ); } #[tokio::test(flavor = "current_thread")] async fn an_h1_scalar_return_still_reads_var_back() { - // The read-back half of the H1 return rule: the legacy pass reads the + // The read-back half of the H1 return rule: the pass reads the // final `var` back on every exit, so a reassigned `var` global fails // the run even when the block's scalar return would short-circuit it. let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ @@ -1483,13 +1446,16 @@ async fn an_h1_scalar_return_still_reads_var_back() { ```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect_err("a reassigned `var` global must fail the run"); + assert!( + matches!(error, Error::Lua(_)), + "the read-back failure is machinery around the prompt's chunk, \ + not the failed gate, so it keeps the Lua kind: {error}" + ); assert!( error.to_string().contains("global was reassigned"), "the read-back failure must name the cause: {error}" @@ -1497,96 +1463,218 @@ async fn an_h1_scalar_return_still_reads_var_back() { } #[tokio::test(flavor = "current_thread")] -async fn call_is_a_clear_error_on_the_h1() { - // Mirror of the legacy case of the same name: the H1 VM's control - // globals are stubs - H1 runs before sections exist, so calling one - // fails the run with a message naming the cause. On the scheduler the - // stub must survive the shim base install. +async fn a_shared_replay_failure_in_h1_keeps_its_lua_kind() { + // The other half of the remap's boundary: the shared replay is + // machinery around the prompt's chunk, not the chunk itself, so its + // failure is a prompt bug under the Lua kind, never the H1 gate's + // RequirementsUnmet. The context carries the prompt's real compiled + // shared library, not the empty stand-in the other H1 tests use. let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ - # Test prompt\n\n\ - ```lua\ncall('## Nope')\n```\n"; + # Gate\n\n\ + ```lua shared\n\ + error('shared boom')\n\ + ```\n\n\ + ```lua\n\ + var.ok = true\n\ + ```\n"; let prompt = parse(md); - let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); + let shared = prompt + .replay() + .cloned() + .expect("the prompt's shared chunk compiles at parse"); + let ctx = RunState::new( + &prompt, + "", + &TestStore::new().vfs(), + shared, + &RunContext::new(EXECUTION), + ); let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await - .expect_err("call from the H1 must fail with the stub error"); + .expect_err("a failing shared replay must fail the run"); assert!( - error.to_string().contains("only available in sections"), - "the stub error must name the cause: {error}" + matches!(error, Error::Lua(_) | Error::LuaRuntime { .. }), + "the shared replay failure keeps its Lua kind: {error}" + ); + assert!( + error.to_string().contains("shared boom"), + "the failure names the shared chunk's error: {error}" ); } #[tokio::test(flavor = "current_thread")] -async fn jump_is_a_clear_error_on_the_h1() { - // Mirror of the legacy case of the same name: `jump` from the H1 hits - // the stub - the run fails with the clear message, never a recorded - // jump. +async fn call_from_h1_runs_the_target_as_a_contained_chain() { + // The control stubs are gone: H1 is section 0, so `call` resolves + // against the top-level sections exactly as in any section. let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\njump('## Nope')\n```\n"; + ```lua\nvar.answer = call('## Answer')\n```\n\n\ + ## Result\n\n\ + ```lua\nreturn var.answer\n```\n\n\ + ## Answer\n\n\ + ```lua\nreturn 'called from h1'\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); - let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) + let out = Scheduler::new(&ctx, None) .drive() .await - .expect_err("jump from the H1 must fail with the stub error"); + .expect("call from H1 runs the target section"); + + assert_eq!(out, "called from h1"); +} + +#[tokio::test(flavor = "current_thread")] +async fn call_from_h1_to_an_unknown_section_is_a_catchable_error() { + // A `call` naming no visible section fails as the call's answer: the + // shim raises it at the call site, where an author `pcall` catches it; + // uncaught, it ends the run as the H1 gate failure. + let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + # Test prompt\n\n\ + ```lua\nlocal ok, err = pcall(call, '## Nope'); return tostring(ok) .. ':' .. tostring(err)\n```\n"; + let prompt = parse(md); + let ctx = h1_context(&prompt); + let out = Scheduler::new(&ctx, None) + .drive() + .await + .expect("the caught call failure is the run's result"); assert!( - error.to_string().contains("only available in sections"), - "the stub error must name the cause: {error}" + out.starts_with("false:") && out.contains("## Nope"), + "the caught error names the missing section: {out}" ); } #[tokio::test(flavor = "current_thread")] -async fn fanout_is_a_clear_error_on_the_h1() { - // Mirror of the legacy case of the same name: `fanout` from the H1 - // hits the same stub. +async fn jump_from_h1_starts_the_walk_at_the_target() { + // A jump out of H1 ends the pass and starts the walk at the resolved + // top-level target, skipping the sections before it. + let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + # Test prompt\n\n\ + ```lua\njump('## Target')\n```\n\n\ + ## Skipped\n\n\ + ```lua\nerror('the jump target must skip this section')\n```\n\n\ + ## Target\n\n\ + ```lua\nreturn 'jumped'\n```\n"; + let prompt = parse(md); + let ctx = h1_context(&prompt); + let out = Scheduler::new(&ctx, None) + .drive() + .await + .expect("jump from H1 starts the walk at the target"); + + assert_eq!(out, "jumped"); +} + +#[tokio::test(flavor = "current_thread")] +async fn jump_from_h1_to_an_unknown_section_fails_the_run() { let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\nfanout('## Nope', {'a'})\n```\n"; + ```lua\njump('## Nope')\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await - .expect_err("fanout from the H1 must fail with the stub error"); + .expect_err("jump from H1 to an unknown section must fail"); assert!( - error.to_string().contains("only available in sections"), - "the stub error must name the cause: {error}" + error.to_string().contains("## Nope"), + "the failure names the missing section: {error}" ); } #[tokio::test(flavor = "current_thread")] -async fn list_from_section_is_a_clear_error_on_the_h1() { - // Mirror of the legacy case of the same name: `list_from_section` from - // the H1 hits the same stub. +async fn fanout_from_h1_runs_the_worker_over_the_collection() { + // `fanout` works in H1 as in any section: the worker resolves against + // the top-level sections and the arms join in collection order. let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\nlist_from_section('## Nope')\n```\n"; + ```lua\n\ + local r = fanout('## Worker', {'a', 'b'})\n\ + var.answer = r[1].text .. '|' .. r[2].text\n\ + ```\n\n\ + ## Result\n\n\ + ```lua\nreturn var.answer\n```\n\n\ + ## Worker\n\n\ + ```lua\nreturn 'item:' .. item\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); - let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) + let out = Scheduler::new(&ctx, None) .drive() .await - .expect_err("list_from_section from the H1 must fail with the stub error"); + .expect("fanout from H1 joins the arms"); - assert!( - error.to_string().contains("only available in sections"), - "the stub error must name the cause: {error}" + assert_eq!(out, "item:a|item:b"); +} + +#[tokio::test(flavor = "current_thread")] +async fn the_h1_decision_tool_idiom_runs_before_the_walk() { + // The decision-tool idiom in H1: a local tool with an enum parameter is + // the verdict channel - the model's loop call lands in the Lua handler, + // and the captured verdict drives the run's shape before the walk. This + // needs `tools.add_local` and `models.loop` in H1, both section-only + // before the one-install-path consolidation. + let gateway = ScriptedGateway::start(vec![ + resp_tool_call("call_1", "decide", "{\"choice\":\"use_mcp\"}"), + resp_text("decided"), + ]) + .await; + let md = "---\nname: h1-decision\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ + # Decide\n\n\ + ```lua\n\ + models.default('writer')\n\ + tools.add_local('decide', 'Record the verdict', { choice = 'string' }, function(args)\n\ + var.verdict = args.choice\n\ + return 'recorded'\n\ + end)\n\ + local msgs = messages.new()\n\ + msgs:user('interpret the guidance')\n\ + models.loop(msgs)\n\ + ```\n\n\ + ## Result\n\n\ + ```lua\nreturn var.verdict\n```\n"; + let prompt = parse(md); + let ctx = h1_context(&prompt); + let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) + .drive() + .await + .expect("the H1 decision-tool idiom runs"); + + assert_eq!(out, "use_mcp"); + assert_eq!( + gateway.call_count(), + 2, + "the loop runs the tool-call round and the terminal text round" ); } +#[tokio::test(flavor = "current_thread")] +async fn list_from_section_works_on_the_h1() { + // `list_from_section` resolves over H1's visible set - the whole + // top-level slice. + let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + # Test prompt\n\n\ + ```lua\n\ + local items = list_from_section('## Items')\n\ + var.answer = table.concat(items, ',')\n\ + ```\n\n\ + ## Result\n\n\ + ```lua\nreturn var.answer\n```\n\n\ + ## Items\n\n\ + - one\n\ + - two\n"; + let prompt = parse(md); + let ctx = h1_context(&prompt); + let out = Scheduler::new(&ctx, None) + .drive() + .await + .expect("list_from_section from H1 reads the target's items"); + + assert_eq!(out, "one,two"); +} + #[tokio::test(flavor = "current_thread")] async fn h1_only_lua_return() { // Mirror of the legacy case of the same name: an H1-only prompt's @@ -1596,9 +1684,7 @@ async fn h1_only_lua_return() { ```lua\nreturn \"hello\"\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let out = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect("the H1-only return runs"); @@ -1615,9 +1701,7 @@ async fn h1_only_lua_no_return() { ```lua\nlocal x = 1\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let out = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect("the H1-only fall-through runs"); @@ -1637,9 +1721,7 @@ async fn h1_scalar_return_short_circuits_the_walk() { ```lua\nerror('the walk must not start after an H1 return')\n```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let out = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect("the H1 return short-circuits the run"); @@ -1653,10 +1735,10 @@ async fn h1_prose_inferred_explicitly_is_the_run_result() { // infer ends the run with the inferred text: the scalar return // short-circuits the (empty) walk. let gateway = ScriptedGateway::start(vec![resp_text("h1 reply")]).await; - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Only Prose\n\n\ ```lua\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ ```\n\n\ say something\n\n\ ```lua\n\ @@ -1664,9 +1746,7 @@ async fn h1_prose_inferred_explicitly_is_the_run_result() { ```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::models_only(); let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) - .with_live_h1(resolution.context()) .drive() .await .expect("the H1 infer of its prose ends the run"); @@ -1682,10 +1762,10 @@ async fn h1_and_h2_prose_each_infer_explicitly_in_source_order() { // pass and the H2 section each read their own pending buffer into an // explicit infer - two completions, in source order. let gateway = ScriptedGateway::start(vec![resp_text("h1 reply"), resp_text("h2 reply")]).await; - let md = "---\nname: shared-loop\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: shared-loop\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Shared Loop\n\n\ ```lua\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ ```\n\n\ h1 prose turn\n\n\ ```lua\n\ @@ -1698,9 +1778,7 @@ async fn h1_and_h2_prose_each_infer_explicitly_in_source_order() { ```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::models_only(); let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) - .with_live_h1(resolution.context()) .drive() .await .expect("H1 prose and H2 prose each infer explicitly"); @@ -1744,9 +1822,7 @@ async fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { ```lua\nreturn 'ok'\n```\n"; let prompt = parse(unread); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let out = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect("unread H1 prose must not require a model"); @@ -1758,9 +1834,7 @@ async fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { ```lua\nreturn models.infer(prose)\n```\n"; let prompt = parse(reading); let ctx = h1_context(&prompt); - let resolution = H1Resolution::empty(); let error = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect_err("an explicit infer of H1 prose with no binding must fail"); @@ -1777,10 +1851,10 @@ async fn live_h1_prose_infers_explicitly_and_var_accumulates_into_the_walk() { // the pass reads its pending buffer only through an explicit infer, and // `var` writes accumulate across the pass into the walk. let gateway = ScriptedGateway::start(vec![resp_text("final answer")]).await; - let md = "---\nname: live-h1-prose\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: live-h1-prose\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Live H1 Prose\n\n\ ```lua\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ var.executions = (var.executions or 0) + 1\n\ ```\n\n\ Ask for one round.\n\n\ @@ -1794,9 +1868,7 @@ async fn live_h1_prose_infers_explicitly_and_var_accumulates_into_the_walk() { ```\n"; let prompt = parse(md); let ctx = h1_context(&prompt); - let resolution = H1Resolution::models_only(); let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) - .with_live_h1(resolution.context()) .drive() .await .expect("live H1 prose infers explicitly"); @@ -1818,9 +1890,7 @@ async fn the_live_h1_pass_fires_no_section_boundaries() { ```lua\nreturn 'done-now'\n```\n"; let prompt = parse(md); let ctx = h1_context_on(&prompt, &TestStore::new(), recorder.clone()); - let resolution = H1Resolution::empty(); let out = Scheduler::new(&ctx, None) - .with_live_h1(resolution.context()) .drive() .await .expect("the pass and the walk complete"); diff --git a/crates/promptforge-api/src/execute/tests/tool_scoping.rs b/crates/promptforge-api/src/execute/tests/tool_scoping.rs index 241818b9..ca144074 100644 --- a/crates/promptforge-api/src/execute/tests/tool_scoping.rs +++ b/crates/promptforge-api/src/execute/tests/tool_scoping.rs @@ -115,8 +115,8 @@ async fn h2_add_scopes_an_alias_and_dispatches_the_concrete_tool() { ); let mut vm = SectionVm::new_for_section( &GuardNonce::fresh(), - &bindings, - &ModelSet::default(), + &Arc::new(Mutex::new(bindings)), + &Arc::new(Mutex::new(ModelSet::default())), EXECUTION, &NullObserver::default(), "Only", @@ -140,7 +140,7 @@ async fn h2_add_scopes_an_alias_and_dispatches_the_concrete_tool() { .expect("the add chunk must compile"); vm.run_chunk(&add, &NullObserver::default(), "Only") .expect("tools.add must succeed"); - let (tool_bindings, tool_runtime) = vm.tool_bag_handles(); + let (tool_bindings, tool_runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&tool_bindings, &tool_runtime).expect("tool scope must snapshot"); let (schemas, dispatch) = prepare_scoped_tools(&scope, &[]).expect("schemas must build"); diff --git a/crates/promptforge-api/src/execute/tests/unified_pipeline.rs b/crates/promptforge-api/src/execute/tests/unified_pipeline.rs index 42212c19..4ff3618c 100644 --- a/crates/promptforge-api/src/execute/tests/unified_pipeline.rs +++ b/crates/promptforge-api/src/execute/tests/unified_pipeline.rs @@ -16,11 +16,10 @@ async fn finite_pipeline_runs_the_unified_surface_end_to_end() { .await; let addr = gateway.addr(); - let source = "---\nname: unified\ndescription: d\npromptforge: 0\n---\n\n\ + let source = "---\nname: unified\ndescription: d\npromptforge: 0\ncapabilities:\n - tests/tools\ntools:\n echo: tests/tools/echo\nmodels:\n writer: {}\n---\n\n\ # Unified\n\n\ ```lua\n\ - tools.bind('echo', 'echo capability')\n\ - models.default('writer', 'A general model for tests')\n\ + models.default('writer')\n\ ```\n\n\ ## Draft\n\n\ Summarize in one word: {{ args }}\n\n\ diff --git a/crates/promptforge-api/src/lib.rs b/crates/promptforge-api/src/lib.rs index ddb29c9f..05f2a51a 100644 --- a/crates/promptforge-api/src/lib.rs +++ b/crates/promptforge-api/src/lib.rs @@ -80,7 +80,6 @@ pub(crate) mod lua; pub(crate) mod model; pub(crate) mod observe; pub mod parser; -mod resolve; pub(crate) mod store; pub(crate) mod subst; #[cfg(test)] diff --git a/crates/promptforge-api/src/lua.rs b/crates/promptforge-api/src/lua.rs index d7e9b2a9..7e8480fa 100644 --- a/crates/promptforge-api/src/lua.rs +++ b/crates/promptforge-api/src/lua.rs @@ -12,17 +12,13 @@ //! here unchanged, so existing `promptforge_api::lua::*` paths keep working. pub(crate) use promptforge_lua::{ - CoroStep, LiveBindingProducer, LuaBlockResult, LuaFanoutResult, LuaProgram, MessageContent, - MessageRecord, MessageRole, OverflowReason, ProseState, ScriptReport, SectionVm, ToolBinding, - ToolCallCounts, ToolCallRecord, ToolResolver, ToolSet, ToolView, UserInputOutcome, - append_message_record, current_tool_bindings, dispatch_tool, enrich_sys_model, - install_live_h1_shim_base, install_section_loop_shim, install_section_user_input_shim, - install_store_shims, install_ui, invoke_selected, is_context_overflow, precheck, - project_messages, resolve_model_binding, run_store_op, shim_live_h1_models, + CoroStep, LuaBlockResult, LuaFanoutResult, LuaProgram, MessageContent, MessageRecord, + MessageRole, OverflowReason, ProseState, ScriptReport, SectionVm, ToolBinding, ToolCallCounts, + ToolCallRecord, ToolOutputKind, ToolSet, ToolView, UserInputOutcome, append_message_record, + current_tool_bindings, dispatch_tool, enrich_sys_model, install_section_loop_shim, + install_section_user_input_shim, install_store_shims, install_ui, invoke_selected, + is_context_overflow, precheck, project_messages, resolve_model_binding, run_store_op, }; -#[cfg(test)] -pub(crate) use promptforge_lua::ToolOutputKind; - #[cfg(test)] mod coro_tests; diff --git a/crates/promptforge-api/src/lua/coro_tests.rs b/crates/promptforge-api/src/lua/coro_tests.rs index 0e4a0140..c974850f 100644 --- a/crates/promptforge-api/src/lua/coro_tests.rs +++ b/crates/promptforge-api/src/lua/coro_tests.rs @@ -6,7 +6,7 @@ //! which stays with the executor to keep the dependency one-directional. use std::num::NonZeroU32; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use mlua::{MultiValue, Thread}; use serde_json::json; @@ -105,8 +105,8 @@ fn scheduler_vm_with_tools( let observer: Arc = Arc::new(NullObserver::default()); let mut vm = SectionVm::new_for_section( &GuardNonce::fresh(), - tools, - models, + &Arc::new(Mutex::new(tools.clone())), + &Arc::new(Mutex::new(models.clone())), "test-run", &NullObserver::default(), "Test", diff --git a/crates/promptforge-api/src/model.rs b/crates/promptforge-api/src/model.rs index f18a5ecc..7084fd6e 100644 --- a/crates/promptforge-api/src/model.rs +++ b/crates/promptforge-api/src/model.rs @@ -15,8 +15,8 @@ //! completion error types through [`crate::client`]. pub(crate) use promptforge_model_client::model::{ - CompletionOptions, ModelBindOpts, ModelBinding, ModelCatalog, ModelDescriptor, ModelId, - ModelResolver, ModelSet, ModelView, PickerModelResolver, ResolvedModel, ThinkingMode, + CompletionOptions, ModelBinding, ModelDescriptor, ModelId, ModelInvocation, ModelSet, + ModelView, ThinkingMode, }; #[cfg(test)] diff --git a/crates/promptforge-api/src/model/tests/always.rs b/crates/promptforge-api/src/model/tests/always.rs index 90a75b5f..f5149784 100644 --- a/crates/promptforge-api/src/model/tests/always.rs +++ b/crates/promptforge-api/src/model/tests/always.rs @@ -1,267 +1,171 @@ -//! `models.default` (single- and multi-arg) integration tests. +//! `models.default` (label form) integration tests. use super::*; -/// Compiles and resolves one live H1 declaration fixture. -fn resolve_shared(source: &str) -> Result<(ToolSet, ModelSet)> { - let shared = crate::lua::LuaProgram::compile( +/// Compiles one Lua chunk for a section VM drive. +fn chunk(source: &str) -> crate::lua::LuaProgram { + crate::lua::LuaProgram::compile( source, - "shared", + "chunk", NonZeroU32::new(1).expect("compile source line is non-zero"), EXECUTION, &NullObserver::default(), - "Prompt", - )?; - let tool_resolver = - |_: &str| -> std::result::Result { - unreachable!("no tools") - }; - resolve_live_declarations_for_test( - &shared, - &tool_resolver, - &fixture_resolver, - EXECUTION, - &NullObserver::default(), - "Prompt", + "Section", ) + .expect("test Lua must compile") } #[test] -fn models_always_records_binding() { - let (_tools, models) = resolve_shared( - r#"models.bind("writer", "A tiny model", { thinking = false, temperature = 0 }) - models.default("writer")"#, +fn models_default_takes_a_label_and_parks_the_prompt_wide_default() { + let models = shared_models(vec![bound_role( + "writer", + "A tiny model", + "small", + 8_192, + Some(false), + &["no-thinking"], + )]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); + vm.inject_host("", &json!({}), &fresh_access()).unwrap(); + vm.run_chunk( + &chunk(r#"models.default("writer")"#), + &NullObserver::default(), + "Section", ) - .unwrap(); - assert_eq!(models.default.as_deref(), Some("writer")); + .expect("a bound label becomes the default"); + assert_eq!( + models.lock().expect("set lock").default.as_deref(), + Some("writer") + ); + vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn models_always_returns_inspectable_object() { - let (tools, models) = resolve_shared( - r#"local bound = models.bind("writer", "A tiny model", { - thinking = false, temperature = 0, max_tokens = 256 - }) - assert(bound.name == "writer") - assert(bound.model_id == "small") - assert(bound.description == "A tiny model") - assert(bound.context == 8192) - assert(bound.thinking == false) - assert(bound.temperature == 0) - assert(bound.max_tokens == 256) - local model = models.default("writer") +fn models_default_returns_an_inspectable_handle() { + let models = shared_models(vec![bound_role( + "writer", + "A tiny model", + "small", + 8_192, + Some(false), + &["no-thinking", "fast"], + )]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); + vm.inject_host("", &json!({}), &fresh_access()).unwrap(); + vm.run_chunk( + &chunk( + r#"local model = models.default("writer") + assert(model.label == "writer") assert(model.name == "writer") assert(model.model_id == "small") assert(model.description == "A tiny model") assert(model.context == 8192) assert(model.thinking == false) - assert(model.temperature == 0) - assert(model.max_tokens == 256)"#, - ) - .unwrap(); - assert_eq!(models.default.as_deref(), Some("writer")); - assert_eq!(models.bindings()[0].context().get(), 8_192); - - let vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, + assert(#model.capabilities == 2) + assert(model.capabilities[1] == "no-thinking") + assert(model.capabilities[2] == "fast")"#, + ), &NullObserver::default(), "Section", ) - .expect("section install must expose the same inspectable Model object"); + .expect("the handle exposes the role label and the full keyword set"); vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn models_always_without_prior_bind_fails() { - let error = resolve_shared(r#"models.default("writer")"#).unwrap_err(); - let msg = error.to_string(); - assert!(msg.contains("not declared"), "unexpected error: {msg}"); -} - -#[test] -fn models_always_duplicate_fails() { - let error = resolve_shared( - r#"models.bind("writer", "A tiny model") - models.default("writer") - models.default("writer")"#, - ) - .unwrap_err(); - let msg = error.to_string(); - assert!(msg.contains("at most once"), "unexpected error: {msg}"); -} - -#[test] -fn models_always_installs_exactly() { - let (tools, models) = resolve_shared( - r#"models.bind("writer", "A tiny model") - models.default("writer")"#, - ) - .unwrap(); - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); +fn models_default_rejects_an_unbound_label() { + let models = shared_models(vec![bound_role( + "writer", + "A tiny model", + "small", + 8_192, + None, + &[], + )]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let model = resolve_section_model(&vm).unwrap(); - assert_eq!(model.as_ref().map(ModelBinding::alias), Some("writer")); + let error = vm + .run_chunk( + &chunk(r#"models.default("ghost")"#), + &NullObserver::default(), + "Section", + ) + .expect_err("an unbound label is a hard error"); + assert!( + error + .to_string() + .contains("models.default label \"ghost\" is not a bound model role"), + "the rejection names the label: {error}" + ); vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn models_always_provides_completion_options_without_use() { - let (tools, models) = resolve_shared( - r#"models.bind("writer", "A tiny model", { thinking = false, temperature = 0 }) - models.default("writer")"#, - ) - .unwrap(); - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); +fn models_default_is_idempotent_and_never_changes_mid_run() { + let models = shared_models(vec![ + bound_role("writer", "A tiny model", "small", 8_192, None, &[]), + bound_role( + "critic", + "A careful analysis model", + "analyst", + 131_072, + None, + &[], + ), + ]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let model = resolve_section_model(&vm).unwrap(); - let opts = model.as_ref().map(ModelBinding::completion_options); - let expected = CompletionOptions::new("small") - .with_temperature(0.0) - .expect("0.0 is valid") - .with_thinking(false); - assert_eq!(opts, Some(expected)); - vm.teardown(&NullObserver::default(), "Section"); -} - -#[test] -fn models_always_from_h2_prologue_fails() { - let (tools, models) = resolve_shared(r#"models.bind("writer", "A tiny model")"#).unwrap(); - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, + // The shared library replays into every section, so re-naming the same + // default is a no-op. + vm.run_chunk( + &chunk(r#"models.default("writer"); models.default("writer")"#), &NullObserver::default(), "Section", ) - .unwrap(); - vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let prologue = crate::lua::LuaProgram::compile( - r#"models.default("writer")"#, - "prologue", - NonZeroU32::new(1).expect("compile source line is non-zero"), - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); - let result = vm.run_chunk(&prologue, &NullObserver::default(), "Section"); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); + .expect("re-naming the same default is a no-op"); + let error = vm + .run_chunk( + &chunk(r#"models.default("critic")"#), + &NullObserver::default(), + "Section", + ) + .expect_err("the prompt-wide default cannot change mid-run"); assert!( - msg.contains("only available during live H1 execution"), - "unexpected error: {msg}" + error + .to_string() + .contains("models.default is already \"writer\""), + "the refusal names the parked default: {error}" ); vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn models_always_multi_arg_records_bind_and_always() { - let (_tools, models) = resolve_shared( - r#"models.default("writer", "A tiny model", { thinking = false, temperature = 0 })"#, - ) - .unwrap(); - assert_eq!(models.default.as_deref(), Some("writer")); - assert!(models.binding("writer").is_some()); -} - -#[test] -fn models_always_multi_arg_two_args() { - let (_tools, models) = resolve_shared(r#"models.default("writer", "A tiny model")"#).unwrap(); - assert_eq!(models.default.as_deref(), Some("writer")); - assert!(models.binding("writer").is_some()); -} - -#[test] -fn models_always_multi_arg_provides_completion_options() { - let (tools, models) = resolve_shared( - r#"models.default("writer", "A tiny model", { thinking = false, temperature = 0 })"#, - ) - .unwrap(); - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); +fn models_default_resolves_the_section_model_without_use() { + let models = shared_models(vec![bound_role( + "writer", + "A tiny model", + "small", + 8_192, + Some(false), + &["no-thinking"], + )]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let model = resolve_section_model(&vm).unwrap(); - let opts = model.as_ref().map(ModelBinding::completion_options); - let expected = CompletionOptions::new("small") - .with_temperature(0.0) - .expect("0.0 is valid") - .with_thinking(false); - assert_eq!(opts, Some(expected)); - vm.teardown(&NullObserver::default(), "Section"); -} - -#[test] -fn models_always_multi_arg_installs_exactly() { - let (tools, models) = - resolve_shared(r#"models.default("writer", "A tiny model", { thinking = false })"#) - .unwrap(); - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, + vm.run_chunk( + &chunk(r#"models.default("writer")"#), &NullObserver::default(), "Section", ) - .unwrap(); - vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let model = resolve_section_model(&vm).unwrap(); + .expect("the default parks"); + let model = resolve_section_model(&vm).expect("the resolution reads the shared set"); assert_eq!(model.as_ref().map(ModelBinding::alias), Some("writer")); + let opts = model.as_ref().map(ModelBinding::completion_options); + let expected = CompletionOptions::new("small").with_thinking(false); + assert_eq!(opts, Some(expected)); vm.teardown(&NullObserver::default(), "Section"); } - -#[test] -fn models_always_multi_arg_and_single_arg_cannot_both_be_called() { - let (_tools, models) = resolve_shared( - r#"models.bind("analyst", "careful analysis") - models.default("writer", "A tiny model")"#, - ) - .unwrap(); - assert_eq!(models.default.as_deref(), Some("writer")); - - // Now verify that a second always (single-arg) after multi-arg always fails. - let error = resolve_shared( - r#"models.default("writer", "A tiny model") - models.default("writer")"#, - ) - .unwrap_err(); - let msg = error.to_string(); - assert!(msg.contains("at most once"), "unexpected error: {msg}"); -} - -#[test] -fn models_always_multi_arg_duplicate_alias_fails() { - let error = resolve_shared( - r#"models.bind("writer", "A tiny model") - models.default("writer", "A tiny model")"#, - ) - .unwrap_err(); - let msg = error.to_string(); - assert!( - msg.contains("duplicate") - || msg.contains("Duplicate") - || msg.contains("declared more than once"), - "unexpected error: {msg}" - ); -} diff --git a/crates/promptforge-api/src/model/tests/integration.rs b/crates/promptforge-api/src/model/tests/integration.rs index b4494c9f..61553ec4 100644 --- a/crates/promptforge-api/src/model/tests/integration.rs +++ b/crates/promptforge-api/src/model/tests/integration.rs @@ -1,118 +1,113 @@ -//! Lua-driven `models.bind`/`models.use`/`models.default` integration tests. +//! Lua-driven `models.use` integration tests over pre-filled role bindings. use super::*; -/// Compiles and resolves one live H1 declaration fixture. -fn resolve_shared(source: &str) -> Result<(ToolSet, ModelSet)> { - let shared = crate::lua::LuaProgram::compile( +/// Compiles one Lua chunk for a section VM drive. +fn chunk(source: &str) -> crate::lua::LuaProgram { + crate::lua::LuaProgram::compile( source, - "shared", + "chunk", NonZeroU32::new(1).expect("compile source line is non-zero"), EXECUTION, &NullObserver::default(), - "Prompt", - )?; - let tool_resolver = - |_: &str| -> std::result::Result { - unreachable!("no tools") - }; - resolve_live_declarations_for_test( - &shared, - &tool_resolver, - &fixture_resolver, - EXECUTION, - &NullObserver::default(), - "Prompt", + "Section", ) + .expect("test Lua must compile") } #[test] -fn models_bind_resolves_and_use_selects_section_binding() { - let (tools, models) = resolve_shared( - r#"models.bind("analyst", "careful analysis", { thinking = false, temperature = 0, context = 40000 })"#, - ) - .unwrap(); - assert_eq!(models.bindings()[0].id().name(), "analyst"); - assert_eq!(models.bindings()[0].invocation().thinking, Some(false)); - - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); +fn models_use_selects_a_bound_role_by_label() { + let models = shared_models(vec![bound_role( + "analyst", + "A careful analysis model", + "analyst", + 131_072, + Some(true), + &["thinking", "frontier"], + )]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let prologue = crate::lua::LuaProgram::compile( - r#"models.use("analyst")"#, - "prologue", - NonZeroU32::new(1).expect("compile source line is non-zero"), - EXECUTION, + vm.run_chunk( + &chunk(r#"models.use("analyst")"#), &NullObserver::default(), "Section", ) - .unwrap(); - vm.run_chunk(&prologue, &NullObserver::default(), "Section") - .unwrap(); - let model = resolve_section_model(&vm).unwrap(); - assert_eq!(model.unwrap().alias(), "analyst"); + .expect("a bound label selects"); + let model = resolve_section_model(&vm).expect("the resolution reads the selection"); + let model = model.expect("a selection resolves"); + assert_eq!(model.alias(), "analyst"); + assert_eq!(model.invocation().thinking, Some(true)); + assert_eq!( + model.capabilities(), + &["thinking".to_owned(), "frontier".to_owned()] + ); vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn no_models_use_or_always_leaves_section_unbound() { - let (tools, models) = resolve_shared(r#"models.bind("analyst", "careful analysis")"#).unwrap(); - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); +fn no_use_or_default_leaves_the_section_unbound() { + let models = shared_models(vec![bound_role( + "analyst", + "A careful analysis model", + "analyst", + 131_072, + None, + &[], + )]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let model = resolve_section_model(&vm).unwrap(); + let model = resolve_section_model(&vm).expect("the resolution reads the shared set"); assert!(model.is_none()); vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn constraint_filter_makes_bind_absent() { - let error = - resolve_shared(r#"models.bind("analyst", "careful analysis", { context = 200000 })"#) - .unwrap_err(); - assert!(matches!(error, Error::ModelAbsent { .. })); -} - -#[test] -fn undeclared_models_use_fails_loudly() { - let (tools, models) = resolve_shared(r#"models.bind("analyst", "careful analysis")"#).unwrap(); - let mut vm = section_vm_with_model_bindings( - &tools, - &models, - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); +fn models_use_rejects_an_unbound_label() { + let models = shared_models(vec![bound_role( + "analyst", + "A careful analysis model", + "analyst", + 131_072, + None, + &[], + )]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); vm.inject_host("", &json!({}), &fresh_access()).unwrap(); - let prologue = crate::lua::LuaProgram::compile( - r#"models.use("missing")"#, - "prologue", - NonZeroU32::new(1).expect("compile source line is non-zero"), - EXECUTION, - &NullObserver::default(), - "Section", - ) - .unwrap(); let error = vm - .run_chunk(&prologue, &NullObserver::default(), "Section") - .expect_err("an undeclared model alias must fail"); + .run_chunk( + &chunk(r#"models.use("missing")"#), + &NullObserver::default(), + "Section", + ) + .expect_err("an unbound label must fail"); let rendered = error.to_string(); assert!( - rendered.contains("models.use alias \"missing\" was not declared by models.bind"), - "the error must name the undeclared alias and declaration requirement: {rendered}" + rendered.contains("models.use label \"missing\" is not a bound model role"), + "the error must name the unbound label: {rendered}" + ); + vm.teardown(&NullObserver::default(), "Section"); +} + +#[test] +fn models_bind_is_gone() { + let models = shared_models(vec![]); + let mut vm = section_vm_with_models(&models, &NullObserver::default(), "Section") + .expect("the section VM builds"); + vm.inject_host("", &json!({}), &fresh_access()).unwrap(); + let gone = vm + .run_chunk( + &chunk("return tostring(models.bind)"), + &NullObserver::default(), + "Section", + ) + .expect("the probe runs"); + assert_eq!( + gone, + crate::lua::LuaBlockResult::Returned(Some("nil".to_owned())), + "models.bind is removed" ); vm.teardown(&NullObserver::default(), "Section"); } diff --git a/crates/promptforge-api/src/model/tests/mod.rs b/crates/promptforge-api/src/model/tests/mod.rs index 5998c436..c6cad4ba 100644 --- a/crates/promptforge-api/src/model/tests/mod.rs +++ b/crates/promptforge-api/src/model/tests/mod.rs @@ -1,19 +1,13 @@ use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; -use mlua::Lua; - use super::*; -use crate::lua::{ - LiveBindingProducer, LuaProgram, SectionVm, ToolResolver, ToolSet, resolve_model_binding, -}; +use crate::lua::{SectionVm, ToolSet, resolve_model_binding}; use crate::observe::NullObserver; use crate::store::Access; -use crate::tools::ToolCatalog; use crate::untrusted::GuardNonce; use crate::{Error, Result}; -use promptforge_model_client::Error as GatewayClientError; -use promptforge_model_client::model::{ModelCatalogFiltered, ModelInvocation}; +use promptforge_model_client::model::ModelInvocation; use serde_json::json; const EXECUTION: &str = "model-bind-test"; @@ -36,91 +30,51 @@ fn gateway_id(name: &str) -> ModelId { ModelId::gateway(name).expect("test model alias is valid") } -fn catalog() -> ModelCatalog { - ModelCatalog::new([ - ModelDescriptor::new( - gateway_id("small"), - "A tiny model", - ctx(8_192), - ThinkingMode::Never, - ), - ModelDescriptor::new( - gateway_id("analyst"), - "A careful analysis model", - ctx(131_072), - ThinkingMode::Switchable, - ), - ModelDescriptor::new( - gateway_id("always-think"), - "Always thinks aloud", - ctx(64_000), - ThinkingMode::Always, - ), - ]) - .expect("test catalog has unique model ids") +/// A bound role as prepare's fill records it: label, description, resolved +/// identity, the hard-keyword thinking switch as the frozen invocation, and +/// the role's keyword set. +fn bound_role( + label: &str, + description: &str, + model: &str, + window: u32, + thinking: Option, + capabilities: &[&str], +) -> ModelBinding { + ModelBinding::new( + label, + description, + gateway_id(model), + ModelInvocation { + temperature: None, + max_tokens: None, + thinking, + }, + ctx(window), + ) + .with_capabilities(capabilities.iter().map(|word| (*word).to_owned()).collect()) } -fn fixture_resolver( - description: &str, - opts: &ModelBindOpts, -) -> std::result::Result { - let catalog = catalog(); - let matches = catalog.filtered(opts); - let hit = matches - .iter() - .find(|model| { - (description.contains("analysis") && model.id().name() == "analyst") - || (description.contains("tiny") && model.id().name() == "small") - }) - .ok_or_else(|| GatewayClientError::ModelAbsent { - capability: description.to_owned(), - })?; - Ok(ResolvedModel { - id: hit.id().clone(), - invocation: ModelInvocation::from(opts), - context: hit.context(), - }) +/// Shares a model set the way the run shares its own. +fn shared_models(bindings: Vec) -> Arc> { + Arc::new(Mutex::new(ModelSet::from_parts(bindings, None))) } -fn resolve_live_declarations_for_test( - source: &LuaProgram, - tool_resolver: &dyn ToolResolver, - model_resolver: &dyn ModelResolver, - _execution: &str, - _observer: &dyn crate::observe::Observer, - _section: &str, -) -> Result<(ToolSet, ModelSet)> { - let catalog = ToolCatalog::default(); - let producer = LiveBindingProducer::new( - Arc::new(Mutex::new(ToolSet::default())), - Arc::new(Mutex::new(ModelSet::default())), - ); - let lua = Lua::new(); - let result = lua.scope(|scope| { - producer - .install(&lua, scope, tool_resolver, &catalog, model_resolver) - .map_err(|error| mlua::Error::external(error.to_string()))?; - lua.load(source.source()).exec() - }); - if let Some(error) = producer.take_callback_error()? { - return Err(Error::from(error)); - } - result.map_err(Error::lua)?; - producer.bindings().map_err(Error::from) +/// Shares an empty tool set (these fixtures declare no tool slots). +fn shared_tools() -> Arc> { + Arc::new(Mutex::new(ToolSet::default())) } -fn section_vm_with_model_bindings( - tools: &ToolSet, - models: &ModelSet, - execution: &str, +fn section_vm_with_models( + models: &Arc>, observer: &dyn crate::observe::Observer, section: &str, ) -> Result { let vm = SectionVm::new_for_section( &GuardNonce::fresh(), - tools, + &shared_tools(), models, - execution, + EXECUTION, observer, section, )?; @@ -129,9 +83,9 @@ fn section_vm_with_model_bindings( } /// Reads the section's effective model binding through a view over the VM's -/// frozen set, mirroring the engine's read path. +/// shared set, mirroring the engine's read path. fn resolve_section_model(vm: &SectionVm) -> Result> { - let (models, runtime) = vm.model_bag_handles(); + let (models, runtime) = vm.model_bag_handles()?; resolve_model_binding(&Mutex::new(models), &runtime).map_err(Error::from) } diff --git a/crates/promptforge-api/src/resolve.rs b/crates/promptforge-api/src/resolve.rs deleted file mode 100644 index b69b214d..00000000 --- a/crates/promptforge-api/src/resolve.rs +++ /dev/null @@ -1,702 +0,0 @@ -//! Run-scoped live capability resolution for H1 execution. - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex, OnceLock}; - -use mlua::{Lua, Scope}; -use promptforge_model_client::Error as GatewayClientError; -use promptforge_tool_picker::{Outcome, ToolPicker}; - -use crate::error::SharedSource; -use crate::lua::{LiveBindingProducer, ToolResolver, ToolSet}; -use crate::model::{ - ModelBindOpts, ModelCatalog, ModelResolver, ModelSet, PickerModelResolver, ResolvedModel, -}; -use crate::tools::{ToolCatalog, ToolId}; -use crate::{Error, Result}; - -/// Run-scoped capability resolver and live H1 binding producer. -pub(crate) struct RuntimeResolution<'a> { - tool_resolver: PickerResolver<'a, dyn DecisionSource>, - tools: &'a ToolCatalog, - models: &'a ModelCatalog, - base_picker: Option<&'a ToolPicker>, - producer: LiveBindingProducer, -} - -impl<'a> RuntimeResolution<'a> { - /// Creates one run-scoped resolver over live tool and model catalogs. - /// - /// The tool catalog already guarantees unique tool identities - /// (duplicates are rejected at construction), so no identity scan is - /// needed here. - /// - /// Construction retains only the base picker/embedder (F7): it does NOT - /// pre-build a full model index that model resolution would immediately - /// discard and rebuild from the constraint-filtered subset. The filtered - /// 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: 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(source), - tools, - models, - base_picker: picker, - producer: LiveBindingProducer::new(tool_set, model_set), - } - } - - /// Installs call-time tool and model resolution into an H1 Lua scope. - /// - /// # Errors - /// Returns [`Error::Lua`] when the resolver tables cannot be installed. - pub(crate) fn install<'scope, 'env: 'scope>( - &'env self, - lua: &'env Lua, - scope: &'scope Scope<'scope, 'env>, - ) -> Result<()> { - self.producer - .install(lua, scope, &self.tool_resolver, self.tools, self) - .map_err(Error::from) - } - - /// Returns the first typed error captured by a resolver callback. - /// - /// # Errors - /// Returns [`Error::Lua`] if a binding recorder mutex is poisoned. - pub(crate) fn take_callback_error(&self) -> Result> { - Ok(self - .producer - .take_callback_error() - .map_err(Error::from)? - .map(Error::from)) - } -} - -impl ModelResolver for RuntimeResolution<'_> { - fn resolve( - &self, - description: &str, - opts: &ModelBindOpts, - ) -> std::result::Result { - // An empty catalog resolves every bind as absent without touching the - // picker at all. - if self.models.is_empty() { - return Err(GatewayClientError::ModelAbsent { - 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, picker).resolve(description, opts) - } -} - -/// A resolved capability outcome, normalized once into owned identities. -/// -/// Picker descriptor ids are cloned into owned [`ToolId`]s at decision -/// time (F4) - the picker and the executor speak one id type, so no -/// translation is needed - so a cached decision holds only the stable -/// identities the caller needs; a cache hit produces its typed result from -/// these owned ids without re-cloning full descriptors on every resolve. -#[derive(Debug)] -enum CachedDecision { - Bind(ToolId), - Absent, - Duplicate(Vec), - Ambiguous(Vec), - /// The picker's query failed. The typed - /// [`promptforge_tool_picker::QueryError`] is retained as a shareable source - /// (F4) so the failure chain survives the cache; it is wrapped once here and - /// cloned (an `Arc` bump) into a fresh `Error` on every cache hit. - QueryFailed(SharedSource), - /// 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, -} - -impl CachedDecision { - fn from_picker( - outcome: std::result::Result, promptforge_tool_picker::QueryError>, - ) -> Self { - match outcome { - Ok(Outcome::Bind(tool)) => Self::Bind(tool.id().clone()), - Ok(Outcome::Absent) => Self::Absent, - Ok(Outcome::Duplicate(group)) => { - Self::Duplicate(group.iter().map(|tool| tool.id().clone()).collect()) - } - Ok(Outcome::Ambiguous(group)) => { - Self::Ambiguous(group.iter().map(|tool| tool.id().clone()).collect()) - } - Ok(_) => Self::Unrecognized, - Err(error) => Self::QueryFailed(SharedSource::new(error)), - } - } - - fn result(&self, capability: &str) -> std::result::Result { - match self { - Self::Bind(id) => Ok(id.clone()), - Self::Absent => Err(promptforge_lua::Error::Absent { - capability: capability.to_owned(), - }), - Self::Duplicate(ids) => Err(promptforge_lua::Error::Duplicate { - capability: capability.to_owned(), - candidates: ids.clone(), - }), - Self::Ambiguous(ids) => Err(promptforge_lua::Error::Ambiguous { - capability: capability.to_owned(), - candidates: ids.clone(), - }), - Self::QueryFailed(source) => Err(promptforge_lua::Error::BindQuery { - capability: capability.to_owned(), - source: source.clone(), - }), - Self::Unrecognized => Err(promptforge_lua::Error::Bind { - 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(), - }), - } - } -} - -trait DecisionSource: Send + Sync { - fn decide(&self, capability: &str) -> CachedDecision; - - /// The bind-time conflict scan: near-duplicate pairs among the selected - /// identities, with the typed selection failure retained as a shareable - /// source (F4). - fn near_duplicates( - &self, - ids: &[ToolId], - ) -> std::result::Result, SharedSource>; -} - -impl DecisionSource for ToolPicker { - fn decide(&self, capability: &str) -> CachedDecision { - CachedDecision::from_picker(self.resolve(capability)) - } - - fn near_duplicates( - &self, - ids: &[ToolId], - ) -> std::result::Result, SharedSource> { - ToolPicker::near_duplicates(self, ids) - .map(|pairs| { - pairs - .iter() - .map(|pair| { - ( - pair.first().id().clone(), - pair.second().id().clone(), - pair.similarity(), - ) - }) - .collect() - }) - .map_err(SharedSource::new) - } -} - -/// 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: &[ToolId], - ) -> std::result::Result, SharedSource> { - Ok(Vec::new()) - } -} - -/// One cached, single-flight decision cell for a capability (F1). -type DecisionCell = Arc>; - -#[derive(Debug)] -struct PickerResolver<'a, S: ?Sized> { - source: &'a S, - /// Per-capability decision cache. Each entry is a per-key - /// [`OnceLock`] cell so a concurrent miss for one capability runs - /// [`DecisionSource::decide`] exactly once (single-flight, F1); the global - /// map lock is only held to fetch or insert the cell, never across the - /// expensive query. Holds only normalized outcomes (F2: the former - /// write-only diagnostics map, whose sole reader was a test, is gone). - decisions: Mutex>, -} - -impl<'a, S: ?Sized> PickerResolver<'a, S> { - fn new(source: &'a S) -> Self { - Self { - source, - decisions: Mutex::new(BTreeMap::new()), - } - } - - /// Locks the decision cache, mapping a poisoned lock to a resolver-state - /// error (F3) rather than mislabeling it as a Lua authoring failure. - fn lock_decisions( - &self, - ) -> std::result::Result< - std::sync::MutexGuard<'_, BTreeMap>, - promptforge_lua::Error, - > { - self.decisions.lock().map_err(|_| { - promptforge_lua::Error::Internal("tool picker resolver cache was poisoned") - }) - } -} - -impl ToolResolver for PickerResolver<'_, S> -where - S: DecisionSource + ?Sized, -{ - fn resolve(&self, capability: &str) -> std::result::Result { - // Fetch or create this capability's single-flight cell under a short - // lock that touches only the map, never the picker query. - let cell = { - let mut decisions = self.lock_decisions()?; - Arc::clone( - decisions - .entry(capability.to_owned()) - .or_insert_with(|| Arc::new(OnceLock::new())), - ) - }; - // Single-flight (F1): the first caller to reach an uninitialized cell - // runs the (potentially expensive, re-entrant) picker query exactly - // once; concurrent callers for the SAME capability block on this cell - // until that result is published, then all observe the identical - // decision. Different capabilities hold different cells, so unrelated - // misses never serialize, and the global map lock is not held across - // the query. - let decision = cell.get_or_init(|| self.source.decide(capability)); - decision.result(capability) - } - - fn near_duplicates( - &self, - ids: &[ToolId], - ) -> std::result::Result, promptforge_lua::Error> { - // One id type on both sides of the picker boundary: the selected - // identities forward verbatim and the reported pairs need no - // reconstruction. - self.source.near_duplicates(ids).map_err(|source| { - promptforge_lua::Error::ToolScopeAnalysisSource { - source: Box::new(source), - } - }) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use mlua::Lua; - use serde_json::{Value, json}; - - use super::*; - use crate::lua::LiveBindingProducer; - use crate::model::ModelBindOpts; - use crate::tools::{Tool, ToolError, ToolOutput}; - - fn tid(name: &str) -> ToolId { - ToolId::from_validated(&format!("tests/tools/{name}")) - } - - struct FixtureSource; - - impl DecisionSource for FixtureSource { - fn decide(&self, capability: &str) -> CachedDecision { - match capability { - "first" | "same-one" | "same-two" => CachedDecision::Bind(tid("first")), - "second" => CachedDecision::Bind(tid("second")), - "absent" => CachedDecision::Absent, - "duplicate" => CachedDecision::Duplicate(vec![tid("first"), tid("second")]), - "ambiguous" => CachedDecision::Ambiguous(vec![tid("first"), tid("second")]), - other => CachedDecision::QueryFailed(SharedSource::new(std::io::Error::other( - format!("picker failed for {other}"), - ))), - } - } - - fn near_duplicates( - &self, - ids: &[ToolId], - ) -> std::result::Result, SharedSource> { - Ok(vec![(ids[0].clone(), ids[1].clone(), 0.97)]) - } - } - - #[test] - fn concurrent_misses_run_decide_once_per_capability() { - // F1: many threads racing on the SAME capability must run the expensive - // `decide` exactly once (single-flight), and every racer must observe - // the identical published decision. - use std::sync::atomic::{AtomicUsize, Ordering}; - - struct CountingSource { - calls: AtomicUsize, - } - impl DecisionSource for CountingSource { - fn decide(&self, capability: &str) -> CachedDecision { - self.calls.fetch_add(1, Ordering::SeqCst); - // Simulate an expensive re-entrant query so racers overlap on - // the uninitialized cell. - std::thread::sleep(std::time::Duration::from_millis(25)); - CachedDecision::Bind(tid(capability)) - } - - fn near_duplicates( - &self, - ids: &[ToolId], - ) -> std::result::Result, SharedSource> { - Ok(vec![(ids[0].clone(), ids[1].clone(), 0.0)]) - } - } - - let source = CountingSource { - calls: AtomicUsize::new(0), - }; - let resolver = PickerResolver::new(&source); - std::thread::scope(|scope| { - let handles: Vec<_> = (0..8) - .map(|_| scope.spawn(|| resolver.resolve("same").map(|id| id.name().to_owned()))) - .collect(); - for handle in handles { - assert_eq!(handle.join().expect("thread joins").expect("bound"), "same"); - } - }); - assert_eq!( - source.calls.load(Ordering::SeqCst), - 1, - "decide must run exactly once per capability under concurrent misses" - ); - } - - struct FixtureTool { - id: ToolId, - } - - #[async_trait::async_trait] - impl Tool for FixtureTool { - fn id(&self) -> ToolId { - self.id.clone() - } - - fn wire_name(&self) -> &'static str { - "fixture" - } - - fn description(&self) -> &'static str { - "fixture" - } - - fn parameters_schema(&self) -> Value { - json!({}) - } - - async fn call(&self, _arguments: Value) -> std::result::Result { - Ok(ToolOutput::trusted(String::new())) - } - } - - fn callback_error(source: &FixtureSource, tools: &[Arc], code: &str) -> Error { - let resolver = PickerResolver::new(source); - let catalog = ToolCatalog::new(tools).expect("fixture tools are unique"); - let producer = LiveBindingProducer::new( - Arc::new(Mutex::new(ToolSet::default())), - Arc::new(Mutex::new(ModelSet::default())), - ); - let model_resolver = |description: &str, _: &ModelBindOpts| { - Err(GatewayClientError::ModelAbsent { - capability: description.to_owned(), - }) - }; - let lua = Lua::new(); - let result = lua.scope(|scope| { - producer - .install(&lua, scope, &resolver, &catalog, &model_resolver) - .map_err(mlua::Error::external)?; - lua.load(code).exec() - }); - assert!(result.is_err(), "fixture must fail at the Lua callback"); - Error::from( - producer - .take_callback_error() - .expect("callback recorder must remain usable") - .expect("typed callback error must be retained"), - ) - } - - #[test] - fn picker_outcomes_preserve_typed_errors_and_candidate_order() { - let duplicate = Error::from( - CachedDecision::Duplicate(vec![tid("first"), tid("second")]) - .result("duplicate") - .expect_err("duplicate must fail"), - ); - assert!(matches!( - duplicate, - Error::Duplicate { capability, candidates } - if capability == "duplicate" - && candidates == [tid("first"), tid("second")] - )); - assert!(matches!( - CachedDecision::Absent.result("absent").map_err(Error::from), - Err(Error::Absent { capability }) if capability == "absent" - )); - assert!(matches!( - CachedDecision::Ambiguous(vec![tid("first"), tid("second")]) - .result("ambiguous") - .map_err(Error::from), - Err(Error::Ambiguous { capability, candidates }) - if capability == "ambiguous" && candidates.len() == 2 - )); - // F4: a picker query failure keeps the typed cause as a private - // `#[source]` rather than flattening it into a string. - let query_failed = Error::from( - CachedDecision::QueryFailed(SharedSource::new(std::io::Error::other( - "embedding backend down", - ))) - .result("failed") - .expect_err("a query failure must be an error"), - ); - assert!(matches!( - &query_failed, - Error::BindQuery { capability, .. } if capability == "failed" - )); - let source = std::error::Error::source(&query_failed).expect("cause preserved"); - assert!( - source.to_string().contains("embedding backend down"), - "the picker cause must survive as a source, got {source}" - ); - - // The defensive unrecognized-outcome decision maps to a sourceless bind. - assert!(matches!( - CachedDecision::Unrecognized - .result("weird") - .map_err(Error::from), - Err(Error::Bind { capability, detail }) - if capability == "weird" && detail.contains("unrecognized") - )); - } - - #[test] - fn callback_boundary_retains_absent_and_missing_catalog_errors() { - assert!(matches!( - callback_error( - &FixtureSource, - &[], - "tools.bind('missing', 'absent')" - ), - Error::Absent { capability } if capability == "absent" - )); - assert!(matches!( - callback_error( - &FixtureSource, - &[], - "tools.bind('missing', 'first')" - ), - Error::PickedToolNotLive { alias, id } - if alias == "missing" && id == tid("first") - )); - } - - #[test] - fn live_callbacks_reject_duplicate_aliases_and_identities() { - let tools: Vec> = vec![Arc::new(FixtureTool { id: tid("first") })]; - assert!(matches!( - callback_error( - &FixtureSource, - &tools, - "tools.bind('same', 'first'); tools.bind('same', 'first')" - ), - Error::DuplicateAlias { alias } if alias == "same" - )); - assert!(matches!( - callback_error( - &FixtureSource, - &tools, - "tools.bind('one', 'same-one'); tools.bind('two', 'same-two')" - ), - Error::ToolIdSelectedTwice { id, first_alias, second_alias } - if id == tid("first") - && first_alias == "one" - && second_alias == "two" - )); - } - - #[test] - fn bind_records_near_duplicate_conflicts_symmetrically() { - let tools: Vec> = vec![ - Arc::new(FixtureTool { id: tid("first") }), - Arc::new(FixtureTool { id: tid("second") }), - ]; - let resolver = PickerResolver::new(&FixtureSource); - let catalog = ToolCatalog::new(&tools).expect("fixture tools are unique"); - let producer = LiveBindingProducer::new( - Arc::new(Mutex::new(ToolSet::default())), - Arc::new(Mutex::new(ModelSet::default())), - ); - let model_resolver = |description: &str, _: &ModelBindOpts| { - Err(GatewayClientError::ModelAbsent { - capability: description.to_owned(), - }) - }; - let lua = Lua::new(); - lua.scope(|scope| { - producer - .install(&lua, scope, &resolver, &catalog, &model_resolver) - .map_err(mlua::Error::external)?; - lua.load("tools.bind('one', 'first'); tools.bind('two', 'second')") - .exec() - }) - .expect("both binds succeed: binding records, never fails"); - let (tools, _) = producer.bindings().expect("bindings snapshot"); - let one = tools.binding("one").expect("one is bound"); - let two = tools.binding("two").expect("two is bound"); - // The recorded score is the fixture's f32 widened to f64. - let expected = f64::from(0.97f32); - assert_eq!(one.conflicts().len(), 1); - assert_eq!(one.conflicts()[0].alias, "two"); - assert!((one.conflicts()[0].similarity - expected).abs() < f64::EPSILON); - assert_eq!(two.conflicts().len(), 1); - assert_eq!(two.conflicts()[0].alias, "one"); - assert!((two.conflicts()[0].similarity - expected).abs() < f64::EPSILON); - } - - #[test] - fn catalog_rejects_duplicate_live_ids() { - let tools: Vec> = vec![ - Arc::new(FixtureTool { id: tid("same") }), - Arc::new(FixtureTool { id: tid("same") }), - ]; - let error = ToolCatalog::new(&tools) - .expect_err("a repeated live identity must be rejected at catalog construction"); - assert_eq!(error.duplicate_id(), Some(&tid("same"))); - } - - #[test] - fn near_duplicates_are_forwarded_from_the_source() { - let ids = [tid("first"), tid("second")]; - let pairs = FixtureSource - .near_duplicates(&ids) - .expect("analysis succeeds"); - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0].0, ids[0]); - assert_eq!(pairs[0].1, ids[1]); - assert!((pairs[0].2 - 0.97).abs() < f32::EPSILON); - } - - /// A decision source that counts how many times each capability is decided, - /// so a test can prove the resolver caches (decides at most once) and does - /// not re-query the picker on repeated hits (F5). - struct CountingSource { - counts: Mutex>, - } - - impl CountingSource { - fn new() -> Self { - Self { - counts: Mutex::new(BTreeMap::new()), - } - } - - fn count(&self, capability: &str) -> usize { - self.counts - .lock() - .expect("counts lock") - .get(capability) - .copied() - .unwrap_or(0) - } - } - - impl DecisionSource for CountingSource { - fn decide(&self, capability: &str) -> CachedDecision { - *self - .counts - .lock() - .expect("counts lock") - .entry(capability.to_owned()) - .or_insert(0) += 1; - FixtureSource.decide(capability) - } - - fn near_duplicates( - &self, - _ids: &[ToolId], - ) -> std::result::Result, SharedSource> { - Ok(Vec::new()) - } - } - - #[test] - fn each_capability_is_decided_once_and_returns_a_stable_cached_outcome() { - let source = CountingSource::new(); - let resolver = PickerResolver::new(&source); - - // A successful capability, resolved repeatedly, is decided exactly once - // and returns the same identity every time. - let first_a = resolver.resolve("first").expect("first resolves"); - let first_b = resolver.resolve("first").expect("first resolves again"); - assert_eq!(first_a, first_b); - assert_eq!(first_a, tid("first")); - assert_eq!(source.count("first"), 1, "a hit must not re-decide"); - - // A failing capability is likewise cached: decided once, stable error. - let miss_a = resolver.resolve("absent").expect_err("absent fails"); - let miss_b = resolver.resolve("absent").expect_err("absent fails again"); - assert!(matches!(miss_a, promptforge_lua::Error::Absent { .. })); - assert!(matches!(miss_b, promptforge_lua::Error::Absent { .. })); - assert_eq!( - source.count("absent"), - 1, - "a cached miss must not re-decide" - ); - - // A distinct capability is decided on its own miss. - resolver.resolve("second").expect("second resolves"); - assert_eq!(source.count("second"), 1); - assert_eq!(source.count("first"), 1); - } -} diff --git a/crates/promptforge-api/tests/suite/prepare.rs b/crates/promptforge-api/tests/suite/prepare.rs index 0fdc012f..0c2d059a 100644 --- a/crates/promptforge-api/tests/suite/prepare.rs +++ b/crates/promptforge-api/tests/suite/prepare.rs @@ -1065,6 +1065,25 @@ const DECLARES_OPTIONAL_FUZZY: &str = concat!( "Done.\n", ); +/// A prompt declaring two twin capabilities and two exact slots bound +/// to their same-named, same-described tools. +const DECLARES_TWIN_EXACT_SLOTS: &str = concat!( + "---\n", + "name: declares-twin-slots\n", + "description: d\n", + "promptforge: 0\n", + "capabilities:\n", + " - promptforge/web\n", + " - promptforge/web-mirror\n", + "tools:\n", + " fetch: promptforge/web/fetch\n", + " getter: promptforge/web-mirror/fetch\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "Done.\n", +); + /// The one loaded picker model for this test binary: the fuzzy fill /// rebuilds the environment picker's model over the run's assembled /// catalog, so the picker must carry real weights. @@ -1150,10 +1169,7 @@ fn an_exact_slot_absent_from_an_active_capability_is_not_reported_missing() { .register(Arc::new(ToolFixture::new( "promptforge/web", &[], - vec![described_tool( - "promptforge/web/search", - "Search the web", - )], + vec![described_tool("promptforge/web/search", "Search the web")], ))) .expect("web registers"); let env = Environment::new().registry(registry); @@ -1212,3 +1228,60 @@ fn an_optional_fuzzy_slot_with_no_match_is_skipped_and_logged() { "the skip log line names the alias: {logs}" ); } + +#[test] +fn filled_slots_record_near_duplicate_conflicts_symmetrically() { + // The bind-time conflict scan lives at prepare's slot fill: two + // filled slots whose tools are near-verbatim copies record the clash + // on both aliases, so the scope check fires when both halves enter + // one model-visible scope. The twins share name segment and + // description, so their enriched texts - and vectors - are + // identical: a similarity of 1.0 against the 0.98 twin threshold. + let tools = [ + described_tool("promptforge/web/fetch", "Fetch a web page over HTTP"), + described_tool("promptforge/web-mirror/fetch", "Fetch a web page over HTTP"), + ]; + let catalog = Catalog::new( + tools + .iter() + .map(|tool| { + ToolDescriptor::new( + tool.id(), + tool.description().to_owned(), + tool.parameters_schema(), + ) + }) + .collect(), + ); + let picker = ToolPicker::build_with_model(picker_model(), catalog, Config::default(), None) + .expect("the test picker builds"); + let mut registry = CapabilityRegistry::new(); + for (capability, tool) in [("promptforge/web", 0), ("promptforge/web-mirror", 1)] { + registry + .register(Arc::new(ToolFixture::new( + capability, + &[], + vec![Arc::clone(&tools[tool])], + ))) + .expect("the twin capability registers"); + } + let prompt = parse(DECLARES_TWIN_EXACT_SLOTS, "declares-twin-slots"); + let env = Environment::new().registry(registry).picker(picker); + let (ctx, requirements) = env.prepare(&prompt, RunContext::new("fill-twins")); + assert!(requirements.is_satisfied()); + let bindings = ctx.tool_bindings(); + for (alias, other) in [("fetch", "getter"), ("getter", "fetch")] { + let conflicts = bindings.conflicts(alias); + assert_eq!( + conflicts.len(), + 1, + "{alias} records one clash, with {other}" + ); + assert_eq!(conflicts[0].alias, other); + assert!( + conflicts[0].similarity >= 0.98, + "the recorded score is the picker's similarity: {}", + conflicts[0].similarity + ); + } +} diff --git a/crates/promptforge-api/tests/suite/support.rs b/crates/promptforge-api/tests/suite/support.rs index f02debec..586b3a21 100644 --- a/crates/promptforge-api/tests/suite/support.rs +++ b/crates/promptforge-api/tests/suite/support.rs @@ -10,7 +10,7 @@ use promptforge_api::parser::Prompt; use promptforge_store::{StoreError, StoreExt}; use promptforge_tool_picker::{Catalog, Config, ToolPicker}; use shared_promptforge_api::observe::{Observation, Observer}; -use shared_promptforge_api::tools::{Tool, ToolCatalog}; +use shared_promptforge_api::tools::Tool; use shared_vfs::{Origin, VfsRef}; /// One correlated observation: which execution and section emitted it, plus the @@ -41,10 +41,11 @@ pub(super) struct RunOptions { pub(super) observer: Arc, } -/// Prepares a fixture run against a fixture environment (dummy picker, -/// the given tools) and returns the prepared context plus the run's own -/// VFS handle - the prepared router - for seeding before the run and -/// extraction after. +/// Prepares a fixture run against a fixture environment (dummy picker) +/// and returns the prepared context plus the run's own VFS handle - the +/// prepared router - for seeding before the run and extraction after. +/// The fixture tools ride the picker catalog only; contributing them to a +/// run takes a capability and a declared slot. pub(super) fn prepare_run( prompt: &Prompt, tools: &[Arc], @@ -57,8 +58,8 @@ pub(super) fn prepare_run( None, ) .expect("empty fixture picker must build"); - let tools = ToolCatalog::new(tools).expect("fixture tools are unique"); - let env = Environment::new().picker(picker).tools(tools); + let _ = tools; + let env = Environment::new().picker(picker); let ctx = RunContext::new(opts.execution).observer(opts.observer); let (ctx, requirements) = env.prepare(prompt, ctx); assert!( diff --git a/crates/promptforge-lua/benches/surface.rs b/crates/promptforge-lua/benches/surface.rs index c95e9200..75587034 100644 --- a/crates/promptforge-lua/benches/surface.rs +++ b/crates/promptforge-lua/benches/surface.rs @@ -15,6 +15,7 @@ )] use std::num::NonZeroU32; +use std::sync::{Arc, Mutex}; use criterion::{Criterion, criterion_group, criterion_main}; use promptforge_lua::{ @@ -34,8 +35,8 @@ const SECTION: &str = "Bench"; fn builder_vm() -> SectionVm { let mut vm = SectionVm::new_for_section( &GuardNonce::fresh(), - &ToolSet::default(), - &ModelSet::default(), + &Arc::new(Mutex::new(ToolSet::default())), + &Arc::new(Mutex::new(ModelSet::default())), EXECUTION, &NullObserver::default(), SECTION, diff --git a/crates/promptforge-lua/src/alias.rs b/crates/promptforge-lua/src/alias.rs new file mode 100644 index 00000000..85bcd2d4 --- /dev/null +++ b/crates/promptforge-lua/src/alias.rs @@ -0,0 +1,74 @@ +//! The one prompt-local alias grammar, shared by the `tools` and `models` +//! host tables. + +use crate::{Error, Result}; + +/// Validates a prompt-local alias against the supported wire grammar. +/// +/// Aliases are the only names the model sees; tool slots and model roles +/// share the one rule. +/// +/// # Errors +/// Returns [`Error::Lua`] when `alias` is empty, exceeds 64 bytes, starts with +/// a non-letter, or contains a character other than a letter, digit, `_`, or +/// `-` after its first byte. +pub(crate) fn validate_alias(alias: &str) -> Result<()> { + let bytes = alias.as_bytes(); + let valid = (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphabetic() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); + if valid { + Ok(()) + } else { + Err(Error::Lua(format!( + "invalid alias {alias:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_alias_grammar_accepts_letters_digits_underscores_and_dashes() { + for alias in [ + "a", + "A", + "search", + "web_fetch", + "web-fetch2", + &"z".repeat(64), + ] { + assert!(validate_alias(alias).is_ok(), "{alias:?} must validate"); + } + } + + #[test] + fn the_alias_grammar_rejects_every_other_shape() { + for alias in [ + "", + "1search", + "_search", + "-search", + "web fetch", + "web.fetch", + "web/fetch", + &"a".repeat(65), + ] { + assert!( + validate_alias(alias).is_err(), + "{alias:?} must not validate" + ); + } + let error = validate_alias("1search").expect_err("the message names the grammar"); + assert!( + error + .to_string() + .contains("invalid alias \"1search\": expected [A-Za-z][A-Za-z0-9_-]{0,63}"), + "unexpected message: {error}" + ); + } +} diff --git a/crates/promptforge-lua/src/coro.rs b/crates/promptforge-lua/src/coro.rs index 51cde52b..f76c8307 100644 --- a/crates/promptforge-lua/src/coro.rs +++ b/crates/promptforge-lua/src/coro.rs @@ -31,10 +31,6 @@ const SHIM_SOURCE: &str = include_str!("__impl_coro.lua"); /// this stash there. const CHAT_REGISTRY: &str = "promptforge.impl_coro.chat"; -/// The registry key for the shim's `infer`, stashed by the live H1 base -/// install so each H1 block's fresh live models table can receive it. -const INFER_REGISTRY: &str = "promptforge.impl_coro.infer"; - /// The registry key for the shim's `loop`, stashed by the prelude install so /// a section VM's host can install it as `models.loop`. The registry is /// host-side only: an agent VM's `models.loop` stays nil because nothing @@ -48,8 +44,8 @@ const LOOP_REGISTRY: &str = "promptforge.impl_coro.loop"; const USER_INPUT_REGISTRY: &str = "promptforge.impl_coro.user_input"; /// The registry key for the shim's store function table, stashed by the -/// prelude and the live H1 base install so the executor can install the -/// store yield shims onto a VM's `store` table. The registry is host-side +/// prelude install so the executor can install the store yield shims onto a +/// VM's `store` table. The registry is host-side /// only: an agent VM never installs them, so its store table keeps the /// direct closures - the agent driver is a single-identity loop with no /// interleaving for the claims model to govern. @@ -177,45 +173,6 @@ pub fn install_agent_chat_shim(lua: &Lua) -> Result<()> { models.raw_set("chat", chat).map_err(Error::lua) } -/// Installs the live H1 shim base: the coroutine standard library for the -/// yield capture, and the shim prelude's `infer` stashed in the registry so -/// each H1 block's fresh live models table can receive it through -/// [`shim_live_h1_models`]. -/// -/// The H1 control stubs are untouched: `call`/`fanout`/`jump`/ -/// `list_from_section` keep raising before anything can yield. H1's live -/// models table does not exist at construction (the capability resolvers -/// install it per block), so the prelude runs with nil namespace tables and -/// only its captures are taken. -/// -/// # Errors -/// Returns [`Error::Lua`] if the coroutine library, the shim chunk, or any -/// install step fails. -pub fn install_live_h1_shim_base(lua: &Lua) -> Result<()> { - lua.load_std_libs(StdLib::COROUTINE).map_err(Error::lua)?; - let globals = lua.globals(); - let coroutine: Table = globals.raw_get("coroutine").map_err(Error::lua)?; - let yield_fn: Function = coroutine.raw_get("yield").map_err(Error::lua)?; - let var_snapshot = lua - .create_function(|lua, ()| var_snapshot_table(lua).map_err(mlua::Error::external)) - .map_err(Error::lua)?; - let program = SHIM_PROGRAM.as_ref().map_err(Error::shared)?; - let shims: Table = program - .load(lua)? - .call((yield_fn, var_snapshot, Value::Nil, Value::Nil)) - .map_err(Error::lua)?; - let infer: Function = shims.raw_get("infer").map_err(Error::lua)?; - lua.set_named_registry_value(INFER_REGISTRY, infer) - .map_err(Error::lua)?; - let store: Table = shims.raw_get("store").map_err(Error::lua)?; - lua.set_named_registry_value(STORE_REGISTRY, store) - .map_err(Error::lua)?; - globals - .raw_set("coroutine", Value::Nil) - .map_err(Error::lua)?; - Ok(()) -} - /// Installs the store yield shims onto a VM's `store` table, replacing the /// direct closures the host API install put there. Every store operation /// then suspends the block as a leaf yield the driver answers against the @@ -229,9 +186,8 @@ pub fn install_live_h1_shim_base(lua: &Lua) -> Result<()> { /// store table keeps the direct closures. /// /// # Errors -/// Returns [`Error::Lua`] if the shim prelude (or the live H1 base -/// install) never ran on this VM, the `store` table is absent, or the -/// install fails. +/// Returns [`Error::Lua`] if the shim prelude never ran on this VM, the +/// `store` table is absent, or the install fails. pub fn install_store_shims(lua: &Lua) -> Result<()> { let shims: Table = lua .named_registry_value(STORE_REGISTRY) @@ -243,23 +199,3 @@ pub fn install_store_shims(lua: &Lua) -> Result<()> { } Ok(()) } - -/// Gives one live H1 block's freshly installed live models table the yield -/// shim as its `models.infer`. -/// -/// Reapplied on every H1 coroutine step: the capability resolvers install -/// a fresh live models table per step's scope, so each resume re-installs -/// the shim on the fresh table before the thread runs again. The handles -/// `models.bind`/`models.default` return are plain userdata: invocation is -/// namespace-only, `models.infer(handle?, prompt)`. -/// -/// # Errors -/// Returns [`Error::Lua`] if the base install never ran on this VM or the -/// live models table is absent. -pub fn shim_live_h1_models(lua: &Lua) -> Result<()> { - let infer: Function = lua - .named_registry_value(INFER_REGISTRY) - .map_err(Error::lua)?; - let models: Table = lua.globals().raw_get("models").map_err(Error::lua)?; - models.raw_set("infer", infer).map_err(Error::lua) -} diff --git a/crates/promptforge-lua/src/error.rs b/crates/promptforge-lua/src/error.rs index 95478453..98c20ae2 100644 --- a/crates/promptforge-lua/src/error.rs +++ b/crates/promptforge-lua/src/error.rs @@ -11,7 +11,6 @@ use promptforge_model_client::Error as GatewayClientError; use promptforge_model_client::model::ModelId; -use shared_promptforge_api::tools::ToolId; /// A type-erased owned error cause used by the internal substrate. pub(crate) type BoxedSource = Box; @@ -154,102 +153,6 @@ pub enum Error { #[error("internal invariant violated: {0}")] Internal(&'static str), - /// One prompt-local alias was declared more than once. - #[error("tool alias {alias:?} was declared more than once")] - DuplicateAlias { - /// The exact case-sensitive alias declared by the prompt. - alias: String, - }, - - /// A picker-selected stable identity is not callable in the live tool - /// catalog. - #[error( - "alias {alias:?} selected tool identity {id:?}, which is absent from the live tool catalog" - )] - PickedToolNotLive { - /// The prompt-local alias whose selection cannot be fulfilled. - alias: String, - /// The selected stable identity absent from the catalog. - id: ToolId, - }, - - /// Two prompt-local aliases selected the same stable tool identity. - #[error( - "tool identity {id:?} was selected by both aliases {first_alias:?} and {second_alias:?}" - )] - ToolIdSelectedTwice { - /// The stable identity selected more than once. - id: ToolId, - /// The first alias in declaration order. - first_alias: String, - /// The later conflicting alias. - second_alias: String, - }, - - /// The concrete picker failed while resolving a capability declaration. - #[error("tool capability binding failure for {capability:?}: {detail}")] - Bind { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The picker failure without exposing its concrete error type. - detail: String, - }, - - /// The picker's query failed while resolving a capability, retaining the - /// picker's own typed error as the private `#[source]` cause (resolve F4) - /// so the failure chain survives the resolution cache instead of being - /// flattened to a string. - #[error("tool capability binding failure for {capability:?}: {source}")] - BindQuery { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The picker's typed query failure, kept as a shareable cause. - #[source] - source: SharedSource, - }, - - /// No picker catalog entry matched a declared capability. - #[error("no tool matches capability {capability:?}")] - Absent { - /// The exact capability description passed to `tools.bind`. - capability: String, - }, - - /// One server published duplicate matches for a declared capability. - #[error("duplicate tools match capability {capability:?}: {candidates:?}")] - Duplicate { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The stable identities reported by the picker, in picker order. - candidates: Vec, - }, - - /// The picker could not choose uniquely among capability matches. - #[error("ambiguous tools match capability {capability:?}: {candidates:?}")] - Ambiguous { - /// The exact capability description passed to `tools.bind`. - capability: String, - /// The stable identities reported by the picker, in picker order. - candidates: Vec, - }, - - /// The picker's near-duplicate analysis of the selected tool scope failed, - /// retaining the picker's typed selection error as the private `#[source]` - /// cause (F5) rather than flattening it into `detail`. - #[error("selected tool-scope analysis failure")] - ToolScopeAnalysisSource { - /// The picker's typed selection failure, kept as the cause. - #[source] - source: BoxedSource, - }, - - /// One prompt-local model alias was declared more than once. - #[error("model alias {alias:?} was declared more than once")] - DuplicateModelAlias { - /// The exact case-sensitive alias declared by the prompt. - alias: String, - }, - /// The concrete picker failed while resolving a model capability declaration. #[error("model capability binding failure for {capability:?}: {detail}")] ModelBind { diff --git a/crates/promptforge-lua/src/handles.rs b/crates/promptforge-lua/src/handles.rs index 6992a7d5..6f8deffd 100644 --- a/crates/promptforge-lua/src/handles.rs +++ b/crates/promptforge-lua/src/handles.rs @@ -3,46 +3,10 @@ use super::{ UserDataFields, UserDataMethods, Value, }; -/// Resolves one plain-English capability description to one stable live tool. +/// One near-duplicate clash recorded when the binding was filled. /// -/// This is the deterministic seam used by live H1 resolution. It keeps core -/// independent of any concrete picker implementation while allowing a caller -/// to supply a fixed resolver in tests. -pub trait ToolResolver: Send + Sync { - /// Resolves `description` to a stable tool identity. - /// - /// # Errors - /// Returns a core error when the capability cannot be resolved uniquely. - fn resolve(&self, description: &str) -> Result; - - /// Reports the near-duplicate pairs among the bound `ids` as - /// `(first, second, similarity)` triples, for the bind-time conflict - /// scan. - /// - /// The default reports no pairs: a resolver without similarity - /// knowledge (a fixed test resolver) records no conflicts. - /// - /// # Errors - /// Returns a core error when the analysis backend fails. - fn near_duplicates(&self, ids: &[ToolId]) -> Result> { - let _ = ids; - Ok(Vec::new()) - } -} - -impl ToolResolver for F -where - F: Fn(&str) -> Result + Send + Sync, -{ - fn resolve(&self, description: &str) -> Result { - self(description) - } -} - -/// One near-duplicate clash recorded at bind time. -/// -/// The picker is an H1-phase capability, so the score is copied onto the -/// binding when the clash is recorded; it cannot be recomputed later. +/// The similarity score is copied onto the binding when the clash is +/// recorded; it cannot be recomputed later. #[derive(Debug, Clone)] pub struct Conflict { /// The alias of the other binding in the clashing pair. @@ -79,31 +43,28 @@ pub enum ToolOutputKind { } /// One prompt-local alias bound to one stable live tool identity, carrying -/// the resolved implementation attached at bind time. +/// the resolved implementation attached when the slot was filled. /// -/// The implementation rides with the binding so post-H1 execution (schema -/// preparation, dispatch) never consults the implementation catalog again: a -/// capability whose tool is unavailable fails at the `tools.bind` call, before -/// any binding exists. +/// The implementation rides with the binding so run-time execution (schema +/// preparation, dispatch) never consults the assembled catalog again. #[derive(Clone)] pub struct ToolBinding { /// The exact prompt-local alias. pub alias: String, - /// The declared capability description. + /// The slot's description: the fuzzy `want` text or the tool's own. pub description: String, /// The selected stable live identity. pub id: ToolId, /// Author override for the model-facing schema description. /// - /// Capability text in [`Self::description`] stays the live H1 bind - /// string. When set, the executor advertises this instead of the - /// bound tool's default description. + /// When set, the executor advertises this instead of the bound tool's + /// default description. pub model_description: Option, - /// The resolved implementation, attached at bind time. + /// The resolved implementation, attached at fill time. pub tool: Arc, - /// Near-duplicate clashes with sibling bindings, recorded at bind time. - /// Binding records, never fails: a clash errors only when both halves - /// enter one model-visible scope. + /// Near-duplicate clashes with sibling bindings, recorded when the slot + /// was filled. Binding records, never fails: a clash errors only when + /// both halves enter one model-visible scope. pub conflicts: Vec, /// How a script-initiated `tools.call` resumes this binding's output; /// the model tool loop ignores it. @@ -272,8 +233,8 @@ pub(crate) fn resolve_section_target(value: Value) -> mlua::Result { } } -/// The run's tool set: the prompt-level bindings produced by live H1 -/// execution plus the prompt-wide `always` aliases. +/// The run's tool set: the frontmatter's filled tool slots plus the +/// prompt-wide `always` aliases. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ToolSet { /// The prompt-level bindings in declaration order. @@ -321,12 +282,11 @@ impl ToolSet { /// The read-only view over the run's [`ToolSet`]. /// -/// The run context shares the set as `Arc`; the live H1 pass -/// writes through its own concrete `Arc>` handle, and once -/// that VM is dropped no write handle remains. The trait exposes no -/// mutation, so post-H1 frozenness is structural. Every method locks -/// briefly and returns an owned snapshot: a mutex guard cannot outlive the -/// call. +/// The run context shares the set as `Arc`; section VMs share +/// the same allocation through concrete `Arc>` handles, with +/// `tools.always` the only writer (a prompt-wide fact). The trait exposes no +/// mutation. Every method locks briefly and returns an owned snapshot: a +/// mutex guard cannot outlive the call. pub trait ToolView: Send + Sync { /// Returns an owned snapshot of the bindings in declaration order. /// diff --git a/crates/promptforge-lua/src/lib.rs b/crates/promptforge-lua/src/lib.rs index e4f60668..781cb7a2 100644 --- a/crates/promptforge-lua/src/lib.rs +++ b/crates/promptforge-lua/src/lib.rs @@ -42,19 +42,17 @@ pub(crate) use mlua::{ }; pub(crate) use serde_json::Value as Json; -pub(crate) use promptforge_model_client::model::{ - ModelBinding, ModelResolver, ModelSet, ModelView, -}; +pub(crate) use promptforge_model_client::model::{ModelBinding, ModelSet, ModelView}; pub(crate) use promptforge_store::{Access, Store}; 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::tools::{Tool, ToolId}; pub(crate) use shared_promptforge_api::untrusted::GuardNonce; pub(crate) use crate::compactors::install_compactors; pub(crate) use crate::error::Result; pub(crate) use crate::messages::install_messages; +pub(crate) use crate::models::install_models; pub(crate) use crate::models::{LuaModelHandle, ModelsInferHook}; -pub(crate) use crate::models::{install_h2_models, install_live_models}; #[doc(hidden)] pub use crate::error::{Error, SharedSource}; @@ -84,6 +82,7 @@ pub(crate) fn log_byte_budget(log_events: u32) -> usize { (log_events as usize).saturating_mul(LUA_LOG_CHARACTER_LIMIT) } +mod alias; mod collection; mod compactors; mod error; @@ -99,11 +98,10 @@ mod host; pub use host::install_ui; pub(crate) use host::{install_log, install_store_table, install_untrusted}; mod tools; -pub(crate) use tools::{LuaToolHandle, install_h2_tools, install_tool_call_counts}; +pub(crate) use tools::{LuaToolHandle, install_tool_call_counts, install_tools}; mod vm; pub(crate) use vm::pack_sequence; mod handles; -mod live; mod messages; mod program; mod projection; @@ -121,21 +119,18 @@ mod runtime_events; pub use compactors::{Compactor, OverflowReason, invoke_selected, is_context_overflow, precheck}; #[doc(hidden)] pub use coro::{ - install_agent_chat_shim, install_live_h1_shim_base, install_section_loop_shim, - install_section_user_input_shim, install_store_shims, shim_live_h1_models, + install_agent_chat_shim, install_section_loop_shim, install_section_user_input_shim, + install_store_shims, }; #[doc(hidden)] pub use dispatch::{ScriptReport, ToolDispatch, dispatch_tool}; #[doc(hidden)] pub use handles::{ - Conflict, LuaBlockResult, LuaFanoutResult, ToolBinding, ToolOutputKind, ToolResolver, ToolSet, - ToolView, + Conflict, LuaBlockResult, LuaFanoutResult, ToolBinding, ToolOutputKind, ToolSet, ToolView, }; #[doc(hidden)] pub use host::run_store_op; #[doc(hidden)] -pub use live::LiveBindingProducer; -#[doc(hidden)] pub use models::ModelRuntime; #[doc(hidden)] pub use projection::project_messages; diff --git a/crates/promptforge-lua/src/live.rs b/crates/promptforge-lua/src/live.rs deleted file mode 100644 index d0c78f55..00000000 --- a/crates/promptforge-lua/src/live.rs +++ /dev/null @@ -1,338 +0,0 @@ -use super::{ - Arc, Conflict, Error, Lua, LuaToolHandle, ModelResolver, ModelSet, MultiValue, Mutex, Result, - ToolBinding, ToolCatalog, ToolResolver, ToolSet, install_live_models, -}; -use crate::handles::ToolOutputKind; - -/// Records the first concrete callback error, preserving its typed cause. -fn record_callback_error(errors: &Mutex>, error: Error) -> mlua::Result<()> { - let mut slot = errors - .lock() - .map_err(|_| mlua::Error::external("tool binding recorder was poisoned"))?; - if slot.is_none() { - *slot = Some(error); - } - Ok(()) -} - -/// Run-scoped accumulator populated by live H1 capability calls. -/// -/// The producer is installed into one H1 VM. Every executed `tools.bind`, -/// `models.bind`, and `models.default` call resolves immediately, while skipped -/// Lua branches produce no binding. Both halves write through the run's -/// shared set handles - the same allocations the run context reads through -/// its views - so the walk needs no bindings handoff. The typed callback -/// errors live outside the shared sets, in the producer's own slots. -#[derive(Debug, Clone)] -pub struct LiveBindingProducer { - tools: Arc>, - tool_error: Arc>>, - models: Arc>, - model_error: Arc>>, -} - -impl LiveBindingProducer { - /// Builds a producer whose bindings land in the run's shared sets. - #[must_use] - pub fn new(tools: Arc>, models: Arc>) -> Self { - Self { - tools, - tool_error: Arc::new(Mutex::new(None)), - models, - model_error: Arc::new(Mutex::new(None)), - } - } - - /// Installs live tool and model tables into `lua` for the lifetime of - /// `scope`. - /// - /// # Errors - /// Returns [`Error::Lua`] when either table cannot be installed. - pub fn install<'scope, 'env: 'scope>( - &self, - lua: &'env Lua, - scope: &'scope mlua::Scope<'scope, 'env>, - tool_resolver: &'env dyn ToolResolver, - catalog: &'env ToolCatalog, - model_resolver: &'env dyn ModelResolver, - ) -> Result<()> { - install_live_tools( - lua, - scope, - tool_resolver, - catalog, - &self.tools, - &self.tool_error, - )?; - install_live_models(lua, scope, model_resolver, &self.models, &self.model_error) - } - - /// Returns the first concrete resolver error captured by a Lua callback. - /// - /// This lets the H1 executor preserve typed resolution errors instead of - /// replacing them with mlua's callback wrapper. - /// - /// # Errors - /// Returns [`Error::Lua`] if a binding recorder mutex is poisoned. - pub fn take_callback_error(&self) -> Result> { - let tool_error = self - .tool_error - .lock() - .map_err(|_| Error::Lua("tool binding recorder was poisoned".to_owned()))? - .take(); - let model_error = self - .model_error - .lock() - .map_err(|_| Error::Lua("model binding recorder was poisoned".to_owned()))? - .take(); - Ok(tool_error.or(model_error)) - } - - /// Snapshots all bindings resolved by the live H1 execution so far. - /// - /// 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-api`'s tests, - /// not host API. - /// - /// # Errors - /// Returns [`Error::Lua`] if either set's mutex is poisoned. - #[doc(hidden)] - pub fn bindings(&self) -> Result<(ToolSet, ModelSet)> { - let tools = self - .tools - .lock() - .map_err(|_| Error::Lua("tool binding recorder was poisoned".to_owned()))?; - let models = self - .models - .lock() - .map_err(|_| Error::Lua("model binding recorder was poisoned".to_owned()))?; - Ok((tools.clone(), models.clone())) - } -} - -/// Installs live H1 tool resolution into an existing Lua VM. -/// -/// `tools.bind` consults `resolver` at the point Lua executes the call, verifies -/// the selected identity against the run's tool catalog, attaches the -/// resolved implementation to the recorded binding, and returns an inspectable -/// Tool object populated from it. Each successful bind also runs the -/// near-duplicate scan of the new identity against every existing binding's -/// identity, recording each clash symmetrically on both bindings; binding -/// records, never fails - a clash errors only when both halves later enter -/// one model-visible scope. -/// -/// # Errors -/// Returns [`Error::Lua`] when the Lua table cannot be installed. -#[expect( - clippy::too_many_lines, - reason = "one scoped table keeps its callbacks and shared recorder together" -)] -pub(crate) fn install_live_tools<'scope, 'env: 'scope>( - lua: &'env Lua, - scope: &'scope mlua::Scope<'scope, 'env>, - resolver: &'env dyn ToolResolver, - catalog: &'env ToolCatalog, - set: &Arc>, - errors: &Arc>>, -) -> Result<()> { - let tools = lua.create_table().map_err(Error::lua)?; - - let bind_state = Arc::clone(set); - let bind_errors = Arc::clone(errors); - let bind = scope - .create_function( - move |_, - (alias, description, model_description): (String, String, Option)| - -> mlua::Result { - validate_alias(&alias).map_err(mlua::Error::external)?; - { - let bindings = bind_state - .lock() - .map_err(|_| mlua::Error::external("tool binding recorder was poisoned"))?; - if bindings - .bindings - .iter() - .any(|binding| binding.alias == alias) - { - let error = Error::DuplicateAlias { - alias: alias.clone(), - }; - drop(bindings); - record_callback_error(&bind_errors, error)?; - return Err(mlua::Error::external("duplicate tool alias")); - } - } - let id = match resolver.resolve(&description) { - Ok(id) => id, - Err(error) => { - record_callback_error(&bind_errors, error)?; - return Err(mlua::Error::external("tool capability resolution failed")); - } - }; - let Some(tool) = catalog.get(&id) else { - let error = Error::PickedToolNotLive { - alias: alias.clone(), - id, - }; - record_callback_error(&bind_errors, error)?; - return Err(mlua::Error::external("picked tool is not live")); - }; - let handle = LuaToolHandle::from_live_binding(&alias, &description, tool.as_ref()); - // The identity guard and the scan's id list read under one - // short lock; the picker query itself runs unlocked, since - // the resolver is a re-entrant capability. - let mut ids = { - let bindings = bind_state - .lock() - .map_err(|_| mlua::Error::external("tool binding recorder was poisoned"))?; - if let Some(first) = bindings - .bindings - .iter() - .find(|binding| binding.id == id) - .map(|binding| binding.alias.clone()) - { - let error = Error::ToolIdSelectedTwice { - id: id.clone(), - first_alias: first, - second_alias: alias.clone(), - }; - drop(bindings); - record_callback_error(&bind_errors, error)?; - return Err(mlua::Error::external( - "tool identity was selected more than once", - )); - } - bindings - .bindings - .iter() - .map(|binding| binding.id.clone()) - .collect::>() - }; - ids.push(id.clone()); - // A lone binding has no pairs; skip the query. - let pairs = if ids.len() > 1 { - match resolver.near_duplicates(&ids) { - Ok(pairs) => pairs, - Err(error) => { - record_callback_error(&bind_errors, error)?; - return Err(mlua::Error::external("tool conflict analysis failed")); - } - } - } else { - Vec::new() - }; - let mut bindings = bind_state - .lock() - .map_err(|_| mlua::Error::external("tool binding recorder was poisoned"))?; - // Record each clash symmetrically. The picker reports pairs - // among exactly the ids supplied, so a pair not touching the - // new binding was already recorded when its later half bound. - let mut conflicts = Vec::new(); - for (first, second, similarity) in pairs { - let other = if second == id { - first - } else if first == id { - second - } else { - continue; - }; - let Some(existing) = bindings - .bindings - .iter_mut() - .find(|binding| binding.id == other) - else { - continue; - }; - let similarity = f64::from(similarity); - conflicts.push(Conflict { - alias: existing.alias.clone(), - similarity, - }); - existing.conflicts.push(Conflict { - alias: alias.clone(), - similarity, - }); - } - bindings.bindings.push(ToolBinding { - alias: alias.clone(), - description: description.clone(), - id, - model_description, - tool, - conflicts, - // Author-bound live tools resume as strings; structured - // output is a host-constructed binding's opt-in. - output_kind: ToolOutputKind::Plain, - }); - Ok(handle) - }, - ) - .map_err(Error::lua)?; - tools.set("bind", bind).map_err(Error::lua)?; - - let prompt_wide = Arc::clone(set); - let always = scope - .create_function( - move |_, (alias, model_description): (String, Option)| -> mlua::Result<()> { - validate_alias(&alias).map_err(mlua::Error::external)?; - let mut bindings = prompt_wide - .lock() - .map_err(|_| mlua::Error::external("tool binding recorder was poisoned"))?; - if bindings.always.iter().any(|existing| existing == &alias) { - return Err(mlua::Error::external(format!( - "tools.always alias {alias:?} was recorded more than once" - ))); - } - let Some(binding) = bindings - .bindings - .iter_mut() - .find(|binding| binding.alias == alias) - else { - return Err(mlua::Error::external(format!( - "tools.always alias {alias:?} was not declared by tools.bind" - ))); - }; - if let Some(model_description) = model_description { - binding.model_description = Some(model_description); - } - bindings.always.push(alias); - Ok(()) - }, - ) - .map_err(Error::lua)?; - tools.set("always", always).map_err(Error::lua)?; - - let add = scope - .create_function(|_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external( - "tools.add is only available during H2 recording", - )) - }) - .map_err(Error::lua)?; - tools.set("add", add).map_err(Error::lua)?; - lua.globals().raw_set("tools", tools).map_err(Error::lua) -} - -/// Validates a prompt-local tool alias against the supported wire grammar. -/// -/// # Errors -/// Returns [`Error::Lua`] when `alias` is empty, exceeds 64 bytes, starts with -/// a non-letter, or contains a character other than a letter, digit, `_`, or -/// `-` after its first byte. -pub(crate) fn validate_alias(alias: &str) -> Result<()> { - let bytes = alias.as_bytes(); - let valid = (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); - if valid { - Ok(()) - } else { - Err(Error::Lua(format!( - "invalid tool alias {alias:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" - ))) - } -} diff --git a/crates/promptforge-lua/src/models/decode.rs b/crates/promptforge-lua/src/models/decode.rs deleted file mode 100644 index 00cfb0f1..00000000 --- a/crates/promptforge-lua/src/models/decode.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Shared value decoding for the `models.*` host tables. -//! -//! Parses Lua argument shapes (`models.bind`/`models.default` args and the opts -//! table) and validates scalar option values at the Lua trust boundary. - -use std::num::NonZeroU32; - -use mlua::{MultiValue, Table, Value}; - -use promptforge_model_client::model::{ModelBindOpts, Temperature}; - -use crate::{Error, Result}; - -/// Extracts a single string alias from a `MultiValue` (for the 1-arg form). -pub(crate) fn parse_single_alias(args: &MultiValue, label: &str) -> mlua::Result { - match args.iter().next() { - Some(Value::String(value)) => value - .to_str() - .map_err(|_| mlua::Error::external(format!("{label} alias must be a UTF-8 string"))) - .map(|s| s.to_owned()), - _ => Err(mlua::Error::external(format!( - "{label} expects a string alias as first argument" - ))), - } -} - -pub(crate) fn parse_bind_args( - args: MultiValue, - label: &str, -) -> mlua::Result<(String, String, ModelBindOpts)> { - let mut values = args.into_iter(); - let alias = match values.next() { - Some(Value::String(value)) => value - .to_str() - .map_err(|_| mlua::Error::external(format!("{label} alias must be a UTF-8 string")))? - .to_owned(), - _ => { - return Err(mlua::Error::external(format!( - "{label} expects alias, description, and optional opts table" - ))); - } - }; - let description = match values.next() { - Some(Value::String(value)) => value - .to_str() - .map_err(|_| { - mlua::Error::external(format!("{label} description must be a UTF-8 string")) - })? - .to_owned(), - _ => { - return Err(mlua::Error::external(format!( - "{label} expects alias, description, and optional opts table" - ))); - } - }; - let opts = match values.next() { - None | Some(Value::Nil) => ModelBindOpts::default(), - Some(Value::Table(table)) => parse_opts_table(&table, label)?, - Some(_) => { - return Err(mlua::Error::external(format!( - "{label} opts must be a table when provided" - ))); - } - }; - if values.next().is_some() { - return Err(mlua::Error::external(format!( - "{label} expects at most three arguments" - ))); - } - Ok((alias, description, opts)) -} - -pub(crate) fn parse_opts_table(table: &Table, label: &str) -> mlua::Result { - let mut opts = ModelBindOpts::default(); - for pair in table.pairs::() { - // Propagate the original `mlua::Error` unchanged (PF-LM-012): it already - // carries its source chain, so re-wrapping its text would discard it. - let (key, value) = pair?; - let key = match key { - Value::String(key) => key - .to_str() - .map_err(|_| { - mlua::Error::external(format!("{label} opts key must be a UTF-8 string")) - })? - .to_owned(), - _ => { - return Err(mlua::Error::external(format!( - "{label} opts keys must be strings" - ))); - } - }; - match key.as_str() { - "thinking" => { - opts.thinking = Some(value_as_bool(&value, "thinking", label)?); - } - "context" => { - opts.context = Some(value_as_nonzero_u32(&value, "context", label)?); - } - "temperature" => { - opts.temperature = Some(value_as_temperature(&value, label)?); - } - "max_tokens" => { - opts.max_tokens = Some(value_as_nonzero_u32(&value, "max_tokens", label)?); - } - other => { - return Err(mlua::Error::external(format!( - "unknown {label} opts key {other:?}" - ))); - } - } - } - Ok(opts) -} - -/// Parses and validates a sampling temperature at the Lua trust boundary. -/// -/// Lua integer and number forms are decoded through ONE numeric path (no -/// arbitrary `i32` gate), then validated by the core-owned [`Temperature`] -/// newtype - the single source of truth for the finite `[0.0, 2.0]` domain -/// (PF-LM-004/PF-LM-005). A non-finite (`NaN`, infinity) or out-of-domain value -/// is rejected here rather than forwarded to the gateway, and the validated -/// value travels onward as a `Temperature`, not a raw `f64`. -pub(crate) fn value_as_temperature(value: &Value, label: &str) -> mlua::Result { - let temperature = decode_lua_number(value, "temperature", label)?; - Temperature::new(temperature).map_err(|error| { - mlua::Error::external(format!("{label} opts.temperature is invalid: {error}")) - }) -} - -pub(crate) fn value_as_bool(value: &Value, field: &str, label: &str) -> mlua::Result { - match value { - Value::Boolean(flag) => Ok(*flag), - _ => Err(mlua::Error::external(format!( - "{label} opts.{field} must be a boolean" - ))), - } -} - -pub(crate) fn value_as_u32(value: &Value, field: &str, label: &str) -> mlua::Result { - match value { - Value::Integer(number) => u32::try_from(*number).map_err(|_| { - mlua::Error::external(format!( - "{label} opts.{field} must be a non-negative integer" - )) - }), - Value::Number(number) if number.fract() == 0.0 => { - let truncated = number.trunc(); - if (0.0..=f64::from(u32::MAX)).contains(&truncated) { - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "range checked against u32::MAX and non-negative" - )] - Ok(truncated as u32) - } else { - Err(mlua::Error::external(format!( - "{label} opts.{field} must be a non-negative integer" - ))) - } - } - _ => Err(mlua::Error::external(format!( - "{label} opts.{field} must be a non-negative integer" - ))), - } -} - -/// Decodes a positive Lua count into a [`NonZeroU32`], rejecting zero. -/// -/// Domain counts (`context`, `max_tokens`) must be non-zero (MODEL-003): a zero -/// context minimum is a nonsensical constraint and a zero generation cap would -/// forbid all output. Both are rejected here, at the Lua parse boundary, rather -/// than travelling as an ambiguous `0` toward the wire. -pub(crate) fn value_as_nonzero_u32( - value: &Value, - field: &str, - label: &str, -) -> mlua::Result { - let raw = value_as_u32(value, field, label)?; - NonZeroU32::new(raw).ok_or_else(|| { - mlua::Error::external(format!("{label} opts.{field} must be greater than zero")) - }) -} - -/// Decodes a Lua integer or number into an `f64` through a single path. -/// -/// Both Lua numeric forms are accepted and converted uniformly; the caller's -/// domain check (for example [`value_as_temperature`]) is the single place that -/// bounds the result, so there is no separate, arbitrary integer-range gate. -pub(crate) fn decode_lua_number(value: &Value, field: &str, label: &str) -> mlua::Result { - match value { - Value::Number(number) => Ok(*number), - Value::Integer(number) => { - // Lua integers are i64. The caller bounds the domain (temperatures - // live in [0.0, 2.0]); any magnitude that would lose precision here - // is far outside that domain and rejected by the caller's check. - #[expect( - clippy::cast_precision_loss, - reason = "domain is bounded by the caller; large magnitudes are rejected there" - )] - Ok(*number as f64) - } - _ => Err(mlua::Error::external(format!( - "{label} opts.{field} must be a number" - ))), - } -} - -pub(crate) fn validate_alias(alias: &str) -> Result<()> { - let bytes = alias.as_bytes(); - let valid = (1..=64).contains(&bytes.len()) - && bytes[0].is_ascii_alphabetic() - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); - if valid { - Ok(()) - } else { - Err(Error::Lua(format!( - "invalid model alias {alias:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" - ))) - } -} diff --git a/crates/promptforge-lua/src/models/mod.rs b/crates/promptforge-lua/src/models/mod.rs index a0216cff..6670eb27 100644 --- a/crates/promptforge-lua/src/models/mod.rs +++ b/crates/promptforge-lua/src/models/mod.rs @@ -1,27 +1,27 @@ -//! Lua `models.bind` / `models.use` host tables for live H1 and H2. +//! The Lua `models` host table: `use` / `default` / `get` / `infer`. //! -//! Kept beside the sandbox VM modules so the tool tables stay readable while -//! model declaration recording mirrors their phase rules. +//! Binding is frontmatter: the run's roles arrive pre-filled from prepare in +//! the shared [`ModelSet`], and the table selects among them by label. +//! `models.use` records the section's selection, `models.default` parks the +//! prompt-wide default, `models.get` inspects a bound role without selecting +//! it, and `models.infer` runs the one tool-free round through the +//! executor-installed hook. use std::num::NonZeroU32; use std::sync::Arc; use std::sync::Mutex; -use mlua::{Lua, MultiValue, Scope, Table}; +use mlua::{Lua, Table}; -use promptforge_model_client::model::{ - ModelBindOpts, ModelBinding, ModelId, ModelInvocation, ModelResolver, ModelSet, -}; +use promptforge_model_client::model::{ModelBinding, ModelId, ModelInvocation, ModelSet}; +use crate::alias::validate_alias; use crate::{Error, Result}; -mod decode; mod userdata; pub(crate) use userdata::{LuaModelHandle, ModelsInferHook}; -use decode::{parse_bind_args, parse_single_alias, validate_alias}; - /// The context window a raw gateway-id binding records: catalog metadata /// the hack never sees, so a conservative default keeps the compactor /// precheck safe rather than refusing the model. Mirrors the Workshop's @@ -54,10 +54,10 @@ fn raw_gateway_binding(alias: &str) -> mlua::Result { /// Dispatches a `models.infer(prompt)` call through the executor-installed /// [`ModelsInferHook`] app data. /// -/// Shared by the live H1 and H2 `models` tables; the hook carries everything -/// else (current-model resolution, gateway client, section identity). The -/// call runs the one infer shape: a single tool-free round on a fresh -/// conversation that never sets `reply` or touches `sys`. +/// The hook carries everything else (current-model resolution, gateway +/// client, section identity). The call runs the one infer shape: a single +/// tool-free round on a fresh conversation that never sets `reply` or +/// touches `sys`. fn call_models_infer_hook(lua: &Lua, prompt: &str) -> mlua::Result { let hook = lua .app_data_ref::() @@ -68,7 +68,14 @@ fn call_models_infer_hook(lua: &Lua, prompt: &str) -> mlua::Result { hook(lua, prompt) } -/// H2 model-recording state: wraps the current `models.use` selection. +/// Locks the run's shared model set, mapping a poisoned lock to the Lua +/// boundary error every host callback uses. +fn lock_models(set: &Mutex) -> mlua::Result> { + set.lock() + .map_err(|_| mlua::Error::external("model set mutex was poisoned")) +} + +/// Section model-selection state: wraps the current `models.use` selection. #[derive(Debug)] pub struct ModelRuntime { used: Option, @@ -92,254 +99,95 @@ impl ModelRuntime { } } -/// Records the first concrete callback error, preserving its typed cause. -/// -/// The error slot lives outside the shared [`ModelSet`] (the run context -/// reads that allocation through its view), so a poisoned set lock can never -/// swallow the typed resolution failure the H1 executor reports. -fn record_callback_error(errors: &Mutex>, error: Error) -> mlua::Result<()> { - let mut slot = errors - .lock() - .map_err(|_| mlua::Error::external("model binding recorder was poisoned"))?; - if slot.is_none() { - *slot = Some(error); - } - Ok(()) -} - -/// Records one `models.bind` binding into the shared set. Shared by -/// `models.bind` and the multi-arg `models.default` form. -fn record_bind_binding( - set: &mut ModelSet, - errors: &Mutex>, - resolver: &dyn ModelResolver, - alias: &str, - description: &str, - opts: &ModelBindOpts, -) -> mlua::Result { - if set.bindings.iter().any(|b| b.alias() == alias) { - record_callback_error( - errors, - Error::DuplicateModelAlias { - alias: alias.to_owned(), - }, - )?; - return Err(mlua::Error::external("duplicate model alias")); - } - let selection = match resolver.resolve(description, opts) { - Ok(found) => found, - Err(error) => { - record_callback_error(errors, Error::from(error))?; - return Err(mlua::Error::external("model capability resolution failed")); - } - }; - let binding = ModelBinding::new( - alias, - description, - selection.id, - selection.invocation, - selection.context, - ); - set.bindings.push(binding.clone()); - Ok(binding) -} - -/// Records a `models.default` selection, enforcing at-most-once. -fn record_default_selection(set: &mut ModelSet, alias: String) -> mlua::Result<()> { - if set.default.is_some() { - return Err(mlua::Error::external( - "models.default may be called at most once per prompt", - )); - } - set.default = Some(alias); - Ok(()) -} - -/// Records the multi-argument `models.default(alias, description, opts)` form -/// atomically. -/// -/// All preconditions (the at-most-once `default` rule and, via -/// [`record_bind_binding`], the duplicate-alias and resolution rules) are -/// checked BEFORE any state is mutated, so a rejected call can never leave a -/// half-recorded binding with no matching default alias behind. Only when every -/// precondition passes are the binding and the default alias committed together. -fn record_default_binding( - set: &mut ModelSet, - errors: &Mutex>, - resolver: &dyn ModelResolver, - alias: &str, - description: &str, - opts: &ModelBindOpts, -) -> mlua::Result { - if set.default.is_some() { - return Err(mlua::Error::external( - "models.default may be called at most once per prompt", - )); - } - // `record_bind_binding` only pushes after its own preconditions pass, and we - // have already verified `default` is unset, so this commit is atomic. - let binding = record_bind_binding(set, errors, resolver, alias, description, opts)?; - set.default = Some(alias.to_owned()); - Ok(binding) -} - -/// Installs live H1 `models.bind` / `models.default` resolvers and -/// `models.infer`. +/// Installs the `models` table into one section VM (H1 included: there is +/// one install path for every section). /// -/// Each call resolves immediately and records the resulting frozen binding -/// into the run's shared [`ModelSet`] - the same allocation the run context -/// reads through its `ModelView`. `models.use` remains unavailable until -/// section execution. `models.infer` dispatches through the -/// executor-installed hook, which resolves the current model from the shared -/// set. -pub(crate) fn install_live_models<'scope, 'env: 'scope>( - lua: &'env Lua, - scope: &'scope Scope<'scope, 'env>, - resolver: &'env dyn ModelResolver, - set: &Arc>, - errors: &Arc>>, -) -> Result<()> { - let models = lua.create_table().map_err(Error::lua)?; - - let bind_set = Arc::clone(set); - let bind_errors = Arc::clone(errors); - let bind = scope - .create_function(move |_, args: MultiValue| -> mlua::Result { - let (alias, description, opts) = parse_bind_args(args, "models.bind")?; - validate_alias(&alias).map_err(mlua::Error::external)?; - let mut guard = bind_set - .lock() - .map_err(|_| mlua::Error::external("model binding recorder was poisoned"))?; - let binding = record_bind_binding( - &mut guard, - &bind_errors, - resolver, - &alias, - &description, - &opts, - )?; - Ok(LuaModelHandle::from_binding(&binding)) - }) - .map_err(Error::lua)?; - models.set("bind", bind).map_err(Error::lua)?; - - let default_set = Arc::clone(set); - let default_errors = Arc::clone(errors); - let default = scope - .create_function(move |_, args: MultiValue| -> mlua::Result { - if args.len() >= 2 { - let (alias, description, opts) = parse_bind_args(args, "models.default")?; - validate_alias(&alias).map_err(mlua::Error::external)?; - let mut guard = default_set - .lock() - .map_err(|_| mlua::Error::external("model binding recorder was poisoned"))?; - let binding = record_default_binding( - &mut guard, - &default_errors, - resolver, - &alias, - &description, - &opts, - )?; - Ok(LuaModelHandle::from_binding(&binding)) - } else { - let alias = parse_single_alias(&args, "models.default")?; - validate_alias(&alias).map_err(mlua::Error::external)?; - let mut guard = default_set - .lock() - .map_err(|_| mlua::Error::external("model binding recorder was poisoned"))?; - let binding = guard - .bindings - .iter() - .find(|b| b.alias() == alias) - .cloned() - .ok_or_else(|| { - mlua::Error::external(format!( - "models.default alias {alias:?} was not declared by models.bind" - )) - })?; - record_default_selection(&mut guard, alias)?; - Ok(LuaModelHandle::from_binding(&binding)) - } - }) - .map_err(Error::lua)?; - models.set("default", default).map_err(Error::lua)?; - - let use_fn = scope - .create_function(|_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external( - "models.use is only available during H2 recording", - )) - }) - .map_err(Error::lua)?; - models.set("use", use_fn).map_err(Error::lua)?; - - let infer = scope - .create_function(|lua, prompt: String| call_models_infer_hook(lua, &prompt)) - .map_err(Error::lua)?; - models.set("infer", infer).map_err(Error::lua)?; - - lua.globals().raw_set("models", models).map_err(Error::lua) -} - -/// Switches to H2: forbids `models.bind`, installs `models.use`, -/// `models.get`, and `models.infer`. +/// The table reads and writes the run's shared [`ModelSet`]: `models.use` +/// records the section's own selection in `runtime`, while +/// `models.default(label)` parks the prompt-wide default in the shared set - +/// a static prompt-wide fact, conventionally called from H1 but not +/// privileged to it. Re-selecting the same label is a no-op, so a shared +/// library replayed into every section may name the default; naming a +/// different label errors. There is no `models.bind`: binding is the +/// frontmatter's, and an unknown label is a hard error. /// /// `raw_ids` is the Agent-window model-picker hack: when set, `models.get` /// resolves an undeclared alias as a raw gateway catalog model id, so the /// Workshop chat prompt can run `models.get(ui().selected_model)` without /// declaring its model. Unset, an undeclared alias is the usual error. -pub(crate) fn install_h2_models( +/// +/// The suspending `models.loop` is not installed here: yield cannot cross +/// the Rust callback boundary, so the coroutine shim layer installs it. +/// +/// # Errors +/// Returns [`Error::Lua`] if a Lua table or callback cannot be created or +/// installed. +pub(crate) fn install_models( lua: &Lua, globals: &Table, - bindings: &ModelSet, + set: &Arc>, runtime: &Arc>, raw_ids: bool, ) -> Result<()> { let models = lua.create_table().map_err(Error::lua)?; - let bind = lua - .create_function(|_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external( - "models.bind is only available during live H1 execution", - )) - }) - .map_err(Error::lua)?; - models.set("bind", bind).map_err(Error::lua)?; - - let default_fn = lua - .create_function(|_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external( - "models.default is only available during live H1 execution", - )) - }) - .map_err(Error::lua)?; - models.set("default", default_fn).map_err(Error::lua)?; - - let frozen = bindings.clone(); + let frozen = Arc::clone(set); let state = Arc::clone(runtime); let use_fn = lua - .create_function(move |_, alias: String| -> mlua::Result { - validate_alias(&alias).map_err(mlua::Error::external)?; + .create_function(move |_, label: String| -> mlua::Result { + validate_alias(&label).map_err(mlua::Error::external)?; + let binding = lock_models(&frozen)? + .binding(&label) + .cloned() + .ok_or_else(|| { + mlua::Error::external(format!( + "models.use label {label:?} is not a bound model role" + )) + })?; let mut state = state .lock() .map_err(|_| mlua::Error::external("model declaration runtime was poisoned"))?; - let binding = frozen.binding(&alias).cloned().ok_or_else(|| { - mlua::Error::external(format!( - "models.use alias {alias:?} was not declared by models.bind" - )) - })?; - state.select(alias); + state.select(label); Ok(LuaModelHandle::from_binding(&binding)) }) .map_err(Error::lua)?; models.set("use", use_fn).map_err(Error::lua)?; - let frozen = bindings.clone(); + let frozen = Arc::clone(set); + let default_fn = lua + .create_function(move |_, label: String| -> mlua::Result { + validate_alias(&label).map_err(mlua::Error::external)?; + let mut set = lock_models(&frozen)?; + let binding = set + .binding(&label) + .cloned() + .ok_or_else(|| { + mlua::Error::external(format!( + "models.default label {label:?} is not a bound model role" + )) + })?; + match &set.default { + // Idempotent under the shared-library replay: every + // section re-runs the library, so naming the same default + // again is a no-op. + Some(existing) if existing == &label => {} + Some(existing) => { + return Err(mlua::Error::external(format!( + "models.default is already {existing:?}: the prompt-wide default cannot change mid-run" + ))); + } + None => set.default = Some(label), + } + Ok(LuaModelHandle::from_binding(&binding)) + }) + .map_err(Error::lua)?; + models.set("default", default_fn).map_err(Error::lua)?; + + let frozen = Arc::clone(set); let get_fn = lua .create_function(move |_, alias: String| -> mlua::Result { - if let Some(binding) = frozen.binding(&alias) { - return Ok(LuaModelHandle::from_binding(binding)); + if let Some(binding) = lock_models(&frozen)?.binding(&alias).cloned() { + return Ok(LuaModelHandle::from_binding(&binding)); } // The Agent-window hack: with the host's raw-id opt-in, an // undeclared alias resolves as a raw gateway catalog model id @@ -352,7 +200,7 @@ pub(crate) fn install_h2_models( } validate_alias(&alias).map_err(mlua::Error::external)?; Err(mlua::Error::external(format!( - "models.get alias {alias:?} was not declared by models.bind" + "models.get alias {alias:?} is not a bound model role" ))) }) .map_err(Error::lua)?; diff --git a/crates/promptforge-lua/src/models/tests.rs b/crates/promptforge-lua/src/models/tests.rs index e9e29c2a..53b755e4 100644 --- a/crates/promptforge-lua/src/models/tests.rs +++ b/crates/promptforge-lua/src/models/tests.rs @@ -1,93 +1,8 @@ -use super::decode::{ - decode_lua_number, parse_bind_args, parse_opts_table, parse_single_alias, validate_alias, - value_as_bool, value_as_nonzero_u32, value_as_temperature, value_as_u32, -}; -use super::{ModelRuntime, record_default_binding}; -use mlua::Value; -use mlua::{Lua, MultiValue}; -use promptforge_model_client::model::{ModelBindOpts, ModelId, ModelInvocation, ModelSet}; - -#[test] -fn temperature_accepts_finite_in_domain_and_rejects_the_rest() { - for good in [0.0, 0.7, 1.0, 2.0] { - let got = value_as_temperature(&Value::Number(good), "models.bind") - .expect("in-domain temperature") - .get(); - assert!( - (got - good).abs() <= f64::EPSILON, - "temperature {good} must pass through unchanged, got {got}" - ); - } - for bad in [-0.1, 2.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert!( - value_as_temperature(&Value::Number(bad), "models.bind").is_err(), - "temperature {bad} must be rejected" - ); - } -} - -#[test] -fn integer_and_number_temperatures_share_one_decode_and_domain_check() { - // The Lua integer form is decoded through the same path as the number - // form (no separate i32 gate) and validated by the same domain check. - let from_integer = value_as_temperature(&Value::Integer(1), "models.bind") - .expect("integer 1 is in-domain") - .get(); - let from_number = value_as_temperature(&Value::Number(1.0), "models.bind") - .expect("number 1.0 is in-domain") - .get(); - assert!((from_integer - from_number).abs() <= f64::EPSILON); - assert!( - value_as_temperature(&Value::Integer(5), "models.bind").is_err(), - "an out-of-domain integer temperature must be rejected by the one domain check" - ); -} - -#[test] -fn default_multi_arg_rolls_back_when_already_selected() { - // PF-LM-003: a second multi-arg `models.default` must be rejected WITHOUT - // leaving a half-recorded binding behind. - let resolver = |_: &str, _: &ModelBindOpts| { - Ok(promptforge_model_client::model::ResolvedModel { - id: ModelId::from_validated("gateway", "m1"), - invocation: ModelInvocation::from(&ModelBindOpts::default()), - context: std::num::NonZeroU32::new(8192).expect("8192 is non-zero"), - }) - }; - let mut set = ModelSet::default(); - let errors = std::sync::Mutex::new(None); - record_default_binding( - &mut set, - &errors, - &resolver, - "a", - "desc", - &ModelBindOpts::default(), - ) - .expect("the first models.default must succeed"); - assert_eq!(set.bindings.len(), 1); - assert_eq!(set.default.as_deref(), Some("a")); - - let err = record_default_binding( - &mut set, - &errors, - &resolver, - "b", - "desc", - &ModelBindOpts::default(), - ) - .expect_err("a second models.default must be rejected"); - assert!( - err.to_string().contains("at most once"), - "error must explain the at-most-once rule: {err}" - ); - assert_eq!( - set.bindings.len(), - 1, - "a rejected second models.default must not record a binding (rollback)" - ); - assert_eq!(set.default.as_deref(), Some("a")); -} +use super::{ModelRuntime, install_models}; +use mlua::Lua; +use promptforge_model_client::model::ModelBinding; +use promptforge_model_client::model::{ModelId, ModelInvocation, ModelSet}; +use std::sync::{Arc, Mutex}; #[test] fn model_runtime_select_allows_reselection() { @@ -105,221 +20,153 @@ fn model_runtime_select_allows_reselection() { ); } -// PF-LM-014: direct coverage of every parser branch and state transition. +#[test] +fn model_runtime_starts_with_no_selection() { + let runtime = ModelRuntime::new(); + assert!(runtime.used().is_none(), "fresh runtime has no selection"); +} -fn lua_string(lua: &Lua, value: &str) -> Value { - Value::String(lua.create_string(value).expect("create Lua string")) +/// A bound role for the shared set: `label`, with the keyword set recorded. +fn bound_role(label: &str, capabilities: &[&str]) -> ModelBinding { + ModelBinding::new( + label, + "A general model for tests", + ModelId::from_validated("gateway", "m1"), + ModelInvocation { + temperature: None, + max_tokens: None, + thinking: None, + }, + std::num::NonZeroU32::new(8192).expect("8192 is non-zero"), + ) + .with_capabilities(capabilities.iter().map(|word| (*word).to_owned()).collect()) } -#[test] -fn parse_bind_args_covers_each_branch() { +/// A fresh VM with the `models` table installed over a shared set holding +/// the `writer` and `critic` roles. +fn models_vm() -> (Lua, Arc>, Arc>) { let lua = Lua::new(); - // Missing description. - let one: MultiValue = [lua_string(&lua, "writer")].into_iter().collect(); - assert!( - parse_bind_args(one, "models.bind").is_err(), - "one argument is rejected" - ); - // Non-string alias. - let bad_alias: MultiValue = [Value::Integer(1), lua_string(&lua, "desc")] - .into_iter() - .collect(); - assert!( - parse_bind_args(bad_alias, "models.bind").is_err(), - "non-string alias fails" - ); - // opts not a table. - let bad_opts: MultiValue = [ - lua_string(&lua, "writer"), - lua_string(&lua, "desc"), - Value::Integer(3), - ] - .into_iter() - .collect(); - assert!( - parse_bind_args(bad_opts, "models.bind").is_err(), - "non-table opts fails" - ); - // Too many arguments. - let too_many: MultiValue = [ - lua_string(&lua, "writer"), - lua_string(&lua, "desc"), - Value::Nil, - Value::Nil, - ] - .into_iter() - .collect(); - assert!( - parse_bind_args(too_many, "models.bind").is_err(), - "four arguments fail" - ); - // Valid two-argument form. - let ok: MultiValue = [lua_string(&lua, "writer"), lua_string(&lua, "desc")] - .into_iter() - .collect(); - let (alias, description, opts) = parse_bind_args(ok, "models.bind").expect("valid bind args"); - assert_eq!(alias, "writer"); - assert_eq!(description, "desc"); - assert_eq!(opts.temperature, None); + let set = Arc::new(Mutex::new(ModelSet::from_parts( + vec![ + bound_role("writer", &["frontier", "thinking"]), + bound_role("critic", &["fast"]), + ], + None, + ))); + let runtime = Arc::new(Mutex::new(ModelRuntime::new())); + install_models(&lua, &lua.globals(), &set, &runtime, false) + .expect("the models install cannot fail on a fresh VM"); + (lua, set, runtime) } #[test] -fn parse_opts_table_covers_each_key_and_rejects_unknown() { - let lua = Lua::new(); - let table = lua.create_table().expect("table"); - table.set("thinking", true).expect("set thinking"); - table.set("context", 8192).expect("set context"); - table.set("temperature", 0.5).expect("set temperature"); - table.set("max_tokens", 256).expect("set max_tokens"); - let opts = parse_opts_table(&table, "models.bind").expect("all known keys parse"); - assert_eq!(opts.thinking, Some(true)); - assert_eq!(opts.context.map(std::num::NonZeroU32::get), Some(8192)); - assert_eq!( - opts.temperature - .map(promptforge_model_client::model::Temperature::get), - Some(0.5) - ); - assert_eq!(opts.max_tokens.map(std::num::NonZeroU32::get), Some(256)); - - // MODEL-003: a zero count is rejected at the parse boundary, not stored. - let zero_context = lua.create_table().expect("table"); - zero_context.set("context", 0).expect("set context"); - assert!( - parse_opts_table(&zero_context, "models.bind").is_err(), - "a zero context minimum must be rejected" - ); - let zero_max = lua.create_table().expect("table"); - zero_max.set("max_tokens", 0).expect("set max_tokens"); - assert!( - parse_opts_table(&zero_max, "models.bind").is_err(), - "a zero max_tokens cap must be rejected" - ); +fn the_models_namespace_has_no_bind() { + let (lua, _, _) = models_vm(); + let (bind_is_nil, has_use, has_default, has_get, has_infer): (bool, bool, bool, bool, bool) = + lua.load( + "return models.bind == nil, \ + type(models.use) == 'function', \ + type(models.default) == 'function', \ + type(models.get) == 'function', \ + type(models.infer) == 'function'", + ) + .eval() + .expect("the namespace probe evaluates"); + assert!(bind_is_nil && has_use && has_default && has_get && has_infer); +} - let unknown = lua.create_table().expect("table"); - unknown.set("bogus", 1).expect("set bogus"); - assert!( - parse_opts_table(&unknown, "models.bind").is_err(), - "an unknown opts key must be rejected" - ); +#[test] +fn models_use_selects_a_bound_role_by_label() { + let (lua, _, runtime) = models_vm(); + let handle: String = lua + .load("local h = models.use('writer'); return h.label .. '|' .. h.name") + .eval() + .expect("a bound label selects"); + assert_eq!(handle, "writer|writer"); + assert_eq!(runtime.lock().expect("runtime lock").used(), Some("writer")); +} - let non_string_key = lua.create_table().expect("table"); - non_string_key.set(1, "x").expect("set numeric key"); +#[test] +fn models_use_rejects_an_unbound_label() { + let (lua, _, _) = models_vm(); + let error = lua + .load("models.use('ghost')") + .exec() + .expect_err("an unbound label is a hard error"); assert!( - parse_opts_table(&non_string_key, "models.bind").is_err(), - "a non-string opts key must be rejected" + error + .to_string() + .contains("models.use label \"ghost\" is not a bound model role"), + "the rejection names the label: {error}" ); } #[test] -fn scalar_decoders_cover_valid_and_invalid_inputs() { - assert!(value_as_bool(&Value::Boolean(false), "thinking", "models.bind").is_ok()); - assert!(value_as_bool(&Value::Integer(1), "thinking", "models.bind").is_err()); - - assert_eq!( - value_as_u32(&Value::Integer(7), "context", "models.bind").expect("ok"), - 7 - ); - assert_eq!( - value_as_u32(&Value::Number(9.0), "context", "models.bind").expect("whole number ok"), - 9 - ); - assert!(value_as_u32(&Value::Integer(-1), "context", "models.bind").is_err()); - assert!(value_as_u32(&Value::Number(1.5), "context", "models.bind").is_err()); - assert!(value_as_u32(&Value::Boolean(true), "context", "models.bind").is_err()); - - // MODEL-003: the non-zero decoder accepts positive counts and rejects zero. +fn models_default_takes_a_label_and_parks_the_prompt_wide_default() { + let (lua, set, _) = models_vm(); + lua.load("models.default('writer')") + .exec() + .expect("a bound label becomes the default"); assert_eq!( - value_as_nonzero_u32(&Value::Integer(7), "context", "models.bind") - .expect("positive count") - .get(), - 7 - ); - assert!( - value_as_nonzero_u32(&Value::Integer(0), "context", "models.bind").is_err(), - "a zero count must be rejected" + set.lock().expect("set lock").default.as_deref(), + Some("writer") ); - - assert!( - (decode_lua_number(&Value::Integer(2), "t", "models.bind").expect("int") - 2.0).abs() - < f64::EPSILON - ); - assert!(decode_lua_number(&Value::Boolean(true), "t", "models.bind").is_err()); } #[test] -fn parse_single_alias_and_validate_alias_branches() { - let lua = Lua::new(); - let ok: MultiValue = [lua_string(&lua, "writer")].into_iter().collect(); +fn models_default_is_idempotent_for_the_same_label_and_refuses_a_change() { + let (lua, set, _) = models_vm(); + // The shared library replays into every section, so re-naming the same + // default must be a no-op. + lua.load("models.default('writer'); models.default('writer')") + .exec() + .expect("re-naming the same default is a no-op"); assert_eq!( - parse_single_alias(&ok, "models.default").expect("string alias"), - "writer" - ); - let bad: MultiValue = [Value::Integer(1)].into_iter().collect(); - assert!( - parse_single_alias(&bad, "models.default").is_err(), - "a non-string alias must be rejected" - ); - - assert!(validate_alias("Writer_1-x").is_ok()); - assert!( - validate_alias(&format!("A{}", "2".repeat(63))).is_ok(), - "a 64-character alias must be accepted" + set.lock().expect("set lock").default.as_deref(), + Some("writer") ); + let error = lua + .load("models.default('critic')") + .exec() + .expect_err("the prompt-wide default cannot change mid-run"); assert!( - validate_alias(&format!("A{}", "2".repeat(64))).is_err(), - "a 65-character alias must be rejected" + error + .to_string() + .contains("models.default is already \"writer\""), + "the refusal names the parked default: {error}" ); - assert!(validate_alias("").is_err(), "empty alias rejected"); - assert!(validate_alias("1abc").is_err(), "leading digit rejected"); - assert!(validate_alias("a b").is_err(), "space rejected"); } #[test] -fn live_model_apis_label_nested_decoder_errors_by_entry_point() { - let run = |source: &str| { - let lua = Lua::new(); - let set = std::sync::Arc::new(std::sync::Mutex::new(ModelSet::default())); - let errors = std::sync::Arc::new(std::sync::Mutex::new(None)); - let resolver = |_: &str, _: &ModelBindOpts| { - Ok(promptforge_model_client::model::ResolvedModel { - id: ModelId::from_validated("gateway", "m1"), - invocation: ModelInvocation::from(&ModelBindOpts::default()), - context: std::num::NonZeroU32::new(8192).expect("8192 is non-zero"), - }) - }; - lua.scope(|scope| { - super::install_live_models(&lua, scope, &resolver, &set, &errors) - .map_err(mlua::Error::external)?; - lua.load(source).exec() - }) - .expect_err("the invalid nested scalar must be rejected") - .to_string() - }; - - let bind = run("models.bind('writer', 'desc', { thinking = 1 })"); - assert!( - bind.contains("models.bind opts.thinking must be a boolean"), - "models.bind wording must remain exact: {bind}" - ); - let default = run("models.default('writer', 'desc', { thinking = 1 })"); - assert!( - default.contains("models.default opts.thinking must be a boolean"), - "models.default must identify its own entry point: {default}" - ); +fn models_default_rejects_an_unbound_label() { + let (lua, _, _) = models_vm(); + let error = lua + .load("models.default('ghost')") + .exec() + .expect_err("an unbound label is a hard error"); assert!( - !default.contains("models.bind"), - "models.default errors must not be mislabelled: {default}" + error + .to_string() + .contains("models.default label \"ghost\" is not a bound model role"), + "the rejection names the label: {error}" ); } #[test] -fn model_runtime_starts_with_no_selection() { - let runtime = ModelRuntime::new(); - assert!(runtime.used().is_none(), "fresh runtime has no selection"); +fn the_handle_exposes_label_and_the_full_keyword_set() { + let (lua, _, _) = models_vm(); + let inspected: String = lua + .load( + "local h = models.get('writer'); \ + return h.label .. '|' .. h.model_id .. '|' .. table.concat(h.capabilities, ',')", + ) + .eval() + .expect("the handle inspects"); + assert_eq!(inspected, "writer|m1|frontier,thinking"); } /// Builds a section VM with the Agent-window raw-id opt-in as `raw_ids`, -/// host values injected (which installs the H2 `models` table). +/// host values injected (which installs the `models` table). fn h2_vm(raw_ids: bool) -> crate::SectionVm { let observer = shared_promptforge_api::observe::NullObserver::default(); let mut vm = crate::SectionVm::new( @@ -341,7 +188,7 @@ fn h2_vm(raw_ids: bool) -> crate::SectionVm { .expect("the stock backend acquires"), ), ) - .expect("host injection installs the H2 models table"); + .expect("host injection installs the models table"); vm } @@ -365,10 +212,8 @@ fn models_get_resolves_an_undeclared_alias_as_a_raw_gateway_id_only_when_permitt .exec() .expect_err("without the opt-in an undeclared alias is an error"); assert!( - error - .to_string() - .contains("was not declared by models.bind"), - "the strict path keeps its wording: {error}" + error.to_string().contains("is not a bound model role"), + "the strict path names the bound-role rule: {error}" ); } diff --git a/crates/promptforge-lua/src/models/userdata.rs b/crates/promptforge-lua/src/models/userdata.rs index af8f03e7..810c8f8a 100644 --- a/crates/promptforge-lua/src/models/userdata.rs +++ b/crates/promptforge-lua/src/models/userdata.rs @@ -1,4 +1,5 @@ -//! Inspectable Lua userdata returned by `models.bind` / `models.default`. +//! Inspectable Lua userdata returned by `models.use` / `models.default` / +//! `models.get`. //! //! Presentation only: the userdata exposes a frozen [`ModelBinding`]'s fields //! to Lua. Invocation is namespace-only (A9): the handle carries no methods, @@ -16,13 +17,13 @@ use promptforge_model_client::model::ModelBinding; /// /// Takes only the prompt: the hook resolves the section's current model /// binding itself, because the executor side knows the section name needed -/// for a typed model-required failure and, on the live H1 path, the -/// bindings are still being recorded into the run's producer. +/// for a typed model-required failure. /// Installed as Lua app data; absent app data means `models.infer` is /// unavailable in that context. pub(crate) type ModelsInferHook = Arc mlua::Result + Send + Sync>; -/// Inspectable Lua userdata returned by `models.bind` / `models.default`. +/// Inspectable Lua userdata returned by `models.use` / `models.default` / +/// `models.get`. #[derive(Debug, Clone)] pub(crate) struct LuaModelHandle { binding: ModelBinding, @@ -49,13 +50,26 @@ impl LuaModelHandle { self.binding.alias() } + /// Returns the role label the binding filled (the alias, under the + /// frontmatter's role vocabulary). + #[must_use] + pub(crate) fn label(&self) -> &str { + self.binding.alias() + } + + /// Returns the bound role's full keyword set. + #[must_use] + pub(crate) fn capabilities(&self) -> &[String] { + self.binding.capabilities() + } + /// Returns the caller-facing catalog model id. #[must_use] pub(crate) fn model_id(&self) -> &str { self.binding.id().name() } - /// Returns the capability description supplied to `models.bind`. + /// Returns the capability description of the bound role. #[must_use] pub(crate) fn description(&self) -> &str { self.binding.description() @@ -70,13 +84,13 @@ impl LuaModelHandle { self.binding.context().get() } - /// Returns the frozen thinking switch, when the bind declared one. + /// Returns the frozen thinking switch, when the role declared one. #[must_use] pub(crate) fn thinking(&self) -> Option { self.binding.invocation().thinking } - /// Returns the frozen sampling temperature, when the bind declared one. + /// Returns the frozen sampling temperature, when the role declared one. /// /// The binding stores a validated /// [`Temperature`](promptforge_model_client::model::Temperature); the @@ -89,7 +103,7 @@ impl LuaModelHandle { .map(promptforge_model_client::model::Temperature::get) } - /// Returns the frozen max generation tokens, when the bind declared one. + /// Returns the frozen max generation tokens, when the role declared one. /// /// The binding stores a [`NonZeroU32`](std::num::NonZeroU32); the raw `u32` /// is exposed only here, at the Lua presentation boundary. @@ -105,6 +119,10 @@ impl LuaModelHandle { impl UserData for LuaModelHandle { fn add_fields>(fields: &mut F) { fields.add_field_method_get("name", |_, this| Ok(this.name().to_owned())); + fields.add_field_method_get("label", |_, this| Ok(this.label().to_owned())); + fields.add_field_method_get("capabilities", |lua, this| { + lua.create_sequence_from(this.capabilities().to_vec()) + }); fields.add_field_method_get("model_id", |_, this| Ok(this.model_id().to_owned())); fields.add_field_method_get("description", |_, this| Ok(this.description().to_owned())); fields.add_field_method_get("context", |_, this| Ok(this.context())); diff --git a/crates/promptforge-lua/src/tests.rs b/crates/promptforge-lua/src/tests.rs index 37451bc0..bb716a41 100644 --- a/crates/promptforge-lua/src/tests.rs +++ b/crates/promptforge-lua/src/tests.rs @@ -255,52 +255,37 @@ impl Tool for FixtureTool { } } -fn execute_live_tool_binds( - source: &LuaProgram, - resolver: &dyn ToolResolver, - _execution: &str, - _observer: &dyn Observer, - _section: &str, -) -> Result { - let tools: Vec> = vec![ - Arc::new(FixtureTool("search")), - Arc::new(FixtureTool("fetch")), - ]; - let catalog = ToolCatalog::new(&tools).expect("unique test catalog"); - let models = |description: &str, _: &promptforge_model_client::model::ModelBindOpts| { - Err(promptforge_model_client::Error::ModelAbsent { - capability: description.to_owned(), - }) - }; - let producer = LiveBindingProducer::new( - Arc::new(Mutex::new(ToolSet::default())), - Arc::new(Mutex::new(ModelSet::default())), - ); - let lua = Lua::new(); - harden(&lua)?; - let result = lua.scope(|scope| { - producer - .install(&lua, scope, resolver, &catalog, &models) - .map_err(|error| mlua::Error::external(error.to_string()))?; - lua.load(source.bytecode.as_slice()).exec() - }); - if let Some(error) = producer.take_callback_error()? { - return Err(error); - } - result.map_err(Error::lua)?; - producer.bindings().map(|(tools, _)| tools) +/// Builds a fixture tool set directly: each `(alias, description, fixture)` +/// triple is a bound slot, with `always` aliases parked prompt-wide. This is +/// the shape prepare's filled slots arrive in; no Lua runs to produce it. +fn fixture_set(bindings: &[(&str, &str, &'static str)], always: &[&str]) -> ToolSet { + ToolSet::for_test( + bindings + .iter() + .map(|(alias, description, fixture)| { + ToolBinding::for_test(alias, description, Arc::new(FixtureTool(fixture))) + }) + .collect(), + always.iter().map(|alias| (*alias).to_owned()).collect(), + ) } -fn section_vm_with_bindings( - bindings: &ToolSet, +/// Shares a fixture set the way the run shares its own: one allocation every +/// section VM clones. +fn shared_set(bindings: ToolSet) -> Arc> { + Arc::new(Mutex::new(bindings)) +} + +fn section_vm_with_set( + tools: &Arc>, execution: &str, observer: &dyn Observer, section: &str, ) -> Result { let vm = SectionVm::new_for_section( &test_nonce(), - bindings, - &ModelSet::default(), + tools, + &Arc::new(Mutex::new(ModelSet::default())), execution, observer, section, @@ -309,6 +294,15 @@ fn section_vm_with_bindings( Ok(vm) } +fn section_vm_with_bindings( + bindings: &ToolSet, + execution: &str, + observer: &dyn Observer, + section: &str, +) -> Result { + section_vm_with_set(&shared_set(bindings.clone()), execution, observer, section) +} + /// Builds a section VM through the engine's startup order for a shared /// library: construction, host injection, persistent host APIs, then the /// shared replay. Tests that need control globals or captured bindings add @@ -327,29 +321,6 @@ fn section_vm_with_shared( Ok(vm) } -fn fixture_bindings(source: &str) -> ToolSet { - let shared = program(source); - let resolver = |description: &str| { - Ok(ToolId::parse(&format!( - "fixtures/tools/{}", - if description == "search the web" { - "search" - } else { - "fetch" - }, - )) - .expect("valid id")) - }; - execute_live_tool_binds( - &shared, - &resolver, - EXECUTION, - &NullObserver::default(), - "Prompt", - ) - .expect("fixture binds must resolve") -} - #[test] fn direct_output_is_absent_in_every_executable_lua_vm() { let library = program("assert(print == nil); assert(warn == nil); log('library load')"); @@ -358,20 +329,7 @@ fn direct_output_is_absent_in_every_executable_lua_vm() { .expect("library VM must not expose direct output"); library_vm.teardown(&NullObserver::default(), "Section"); - let shared = program( - "assert(print == nil)\n\ - assert(warn == nil)\n\ - tools.bind('search', 'search the web')", - ); - let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); - let bindings = execute_live_tool_binds( - &shared, - &resolver, - EXECUTION, - &NullObserver::default(), - "Prompt", - ) - .expect("live H1 VM must not expose direct output"); + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); let mut vm = section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") .expect("section VM must not expose direct output"); @@ -783,12 +741,28 @@ fn concurrent_logs_keep_execution_ids_and_local_order() { } #[test] -fn binding_records_exact_aliases_descriptions_identities_and_always_scope() { - let source = "tools.bind('web_search', 'search the web')\n\ - tools.bind('web_fetch2', 'fetch a page')\n\ - tools.always('web_search')"; - let bindings = fixture_bindings(source); +fn filled_slots_record_exact_aliases_descriptions_identities_and_always_scope() { + let set = shared_set(fixture_set( + &[ + ("web_search", "search the web", "search"), + ("web_fetch2", "fetch a page", "fetch"), + ], + &[], + )); + let mut vm = section_vm_with_set(&set, EXECUTION, &NullObserver::default(), "Section") + .expect("the section VM builds over the shared set"); + vm.inject_host("", &json!({}), &fresh_access()) + .expect("host must inject"); + run_scalar( + &vm, + &program("tools.always('web_search')"), + &NullObserver::default(), + "Section", + ) + .expect("tools.always parks the prompt-wide alias"); + vm.teardown(&NullObserver::default(), "Section"); + let bindings = set.lock().expect("the shared set locks"); assert_eq!( bindings .bindings() @@ -804,18 +778,28 @@ fn binding_records_exact_aliases_descriptions_identities_and_always_scope() { } #[test] -fn bind_and_always_record_model_description_overrides() { - let bindings = fixture_bindings( - "tools.bind('web_search', 'search the web', 'bind override')\n\ - tools.bind('web_fetch2', 'fetch a page')\n\ - tools.always('web_fetch2', 'always override')", - ); +fn always_records_a_model_description_override() { + let set = shared_set(fixture_set( + &[ + ("web_search", "search the web", "search"), + ("web_fetch2", "fetch a page", "fetch"), + ], + &[], + )); + let mut vm = section_vm_with_set(&set, EXECUTION, &NullObserver::default(), "Section") + .expect("the section VM builds over the shared set"); + vm.inject_host("", &json!({}), &fresh_access()) + .expect("host must inject"); + run_scalar( + &vm, + &program("tools.always('web_fetch2', 'always override')"), + &NullObserver::default(), + "Section", + ) + .expect("tools.always records the override"); + vm.teardown(&NullObserver::default(), "Section"); - assert_eq!( - bindings.bindings()[0].model_description(), - Some("bind override"), - "tools.bind's third argument records the model-facing override" - ); + let bindings = set.lock().expect("the shared set locks"); assert_eq!( bindings.bindings()[1].model_description(), Some("always override"), @@ -825,7 +809,7 @@ fn bind_and_always_record_model_description_overrides() { #[test] fn tool_handles_are_frozen() { - let bindings = fixture_bindings("search = tools.bind('search', 'search the web')"); + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); let mut vm = section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") .expect("captured bindings must install"); @@ -846,36 +830,31 @@ fn tool_handles_are_frozen() { } #[test] -fn tool_bind_returns_inspectable_object() { - let shared = program( - "local tool = tools.bind('search', 'search the web')\n\ - assert(tool.name == 'search')\n\ - assert(tool.description == 'search the web')\n\ - assert(type(tool.parameters) == 'table')\n\ - assert(tool.wire_name == 'search')\n\ - assert(tool.untrusted == false)\n\ - tools.always('search')", - ); - let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); - let bindings = execute_live_tool_binds( - &shared, - &resolver, - EXECUTION, +fn bound_slot_globals_are_inspectable_tool_objects() { + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); + let mut vm = + section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") + .expect("section install must expose the inspectable Tool object"); + vm.inject_host("", &json!({}), &fresh_access()) + .expect("host must inject"); + run_scalar( + &vm, + &program( + "assert(search.name == 'search')\n\ + assert(search.description == 'search the web')\n\ + assert(type(search.parameters) == 'table')\n\ + assert(search.wire_name == 'search')\n\ + assert(search.untrusted == false)", + ), &NullObserver::default(), - "Prompt", + "Section", ) - .expect("tools.bind must return an inspectable Tool object"); - assert_eq!(bindings.bindings()[0].alias(), "search"); - - let vm = section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") - .expect("section install must expose the same inspectable Tool object"); + .expect("the bound slot's global is an inspectable Tool object"); vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn binding_validates_aliases_exactly() { - let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); - +fn scoping_validates_aliases_exactly() { for alias in [ "", "_leading", @@ -883,123 +862,120 @@ fn binding_validates_aliases_exactly() { "nonasciié", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-a", ] { - let bind = program(&format!("tools.bind({alias:?}, 'capability')")); - let error = execute_live_tool_binds( - &bind, - &resolver, - EXECUTION, + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); + let mut vm = + section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") + .expect("captured bindings must install"); + vm.inject_host("", &json!({}), &fresh_access()) + .expect("host must inject"); + let error = run_scalar( + &vm, + &program(&format!("tools.add({alias:?})")), &NullObserver::default(), - "Prompt", + "Section", ) .expect_err("invalid aliases must be rejected"); assert!( - error.to_string().contains("invalid tool alias"), + error.to_string().contains("invalid alias"), "wrong error for {alias:?}: {error}" ); + vm.teardown(&NullObserver::default(), "Section"); } for valid in ["Upper", "has-dash", &format!("A{}", "2".repeat(63))] { - let bind = program(&format!("tools.bind({valid:?}, 'capability')")); - execute_live_tool_binds( - &bind, - &resolver, - EXECUTION, + let bindings = fixture_set(&[(valid, "a capability", "search")], &[]); + let mut vm = + section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") + .expect("captured bindings must install"); + vm.inject_host("", &json!({}), &fresh_access()) + .expect("host must inject"); + run_scalar( + &vm, + &program(&format!("tools.add({valid:?})")), &NullObserver::default(), - "Prompt", + "Section", ) .expect("planned alias forms must be valid"); + vm.teardown(&NullObserver::default(), "Section"); } } #[test] -fn live_h1_rejects_duplicate_aliases() { - let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); - let error = execute_live_tool_binds( - &program("tools.bind('search', 'one'); tools.bind('search', 'two')"), - &resolver, - EXECUTION, +fn tools_bind_is_gone_from_every_section() { + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); + let mut vm = + section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") + .expect("captured bindings must install"); + vm.inject_host("", &json!({}), &fresh_access()) + .expect("host must inject"); + + let gone = run_scalar( + &vm, + &program("return tostring(tools.bind)"), &NullObserver::default(), - "Prompt", + "Section", ) - .expect_err("duplicate aliases must fail"); - assert!(matches!( - error, - Error::DuplicateAlias { alias } if alias == "search" - )); -} - -#[test] -fn duplicate_alias_error_cannot_be_suppressed_with_lua_pcall() { - let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); - let error = execute_live_tool_binds( - &program("tools.bind('search', 'one'); pcall(tools.bind, 'search', 'two')"), - &resolver, - EXECUTION, + .expect("the probe runs"); + assert_eq!(gone.as_deref(), Some("nil"), "tools.bind is removed"); + let error = run_scalar( + &vm, + &program("tools.bind('other', 'fetch a page')"), &NullObserver::default(), - "Prompt", + "Section", ) - .expect_err("a caught duplicate callback must still fail binding"); - assert!(matches!( - error, - Error::DuplicateAlias { alias } if alias == "search" - )); -} - -#[test] -fn binding_rejects_unknown_and_duplicate_always_aliases() { - let resolver = |_: &str| Ok(ToolId::parse("fixtures/tools/search").expect("valid id")); - for (source, expected) in [ - ( - "tools.always('missing')", - "tools.always alias \"missing\" was not declared by tools.bind", - ), - ( - "tools.bind('search', 'one'); tools.always('search'); tools.always('search')", - "tools.always alias \"search\" was recorded more than once", - ), - ] { - let error = execute_live_tool_binds( - &program(source), - &resolver, - EXECUTION, - &NullObserver::default(), - "Prompt", - ) - .expect_err("invalid always declarations must fail"); - assert!( - error.to_string().contains(expected), - "error must identify the rejected always declaration: {error}" - ); - } + .expect_err("calling the removed tools.bind fails"); + assert!( + error.to_string().contains("nil"), + "a removed function fails as a nil call: {error}" + ); + vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn captured_bindings_do_not_execute_h1_source() { - let bindings = fixture_bindings( - "h1_was_executed = true; \ - tools.bind('search', 'search the web'); \ - tools.always('search')", - ); - let mut vm = - section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") - .expect("captured bindings must install without executing H1"); +fn always_rejects_an_unbound_alias_and_is_idempotent() { + let set = shared_set(fixture_set(&[("search", "search the web", "search")], &[])); + let mut vm = section_vm_with_set(&set, EXECUTION, &NullObserver::default(), "Section") + .expect("the section VM builds over the shared set"); vm.inject_host("", &json!({}), &fresh_access()) .expect("host must inject"); + let error = run_scalar( + &vm, + &program("tools.always('missing')"), + &NullObserver::default(), + "Section", + ) + .expect_err("advertising an unfilled alias is an error"); + assert!( + error + .to_string() + .contains("tools.always alias \"missing\" is not a bound tool slot"), + "the error must identify the unfilled alias: {error}" + ); + // The shared library replays into every section, so re-parking the same + // alias is a no-op, not a duplicate error. run_scalar( &vm, - &program("assert(h1_was_executed == nil); tools.add('search')"), + &program("tools.always('search'); tools.always('search')"), &NullObserver::default(), "Section", ) - .expect("captured binding must be available without H1 execution"); + .expect("re-parking the same alias is idempotent"); + assert_eq!( + set.lock().expect("the shared set locks").always(), + &["search".to_owned()], + "the alias is recorded exactly once" + ); + vm.teardown(&NullObserver::default(), "Section"); } #[test] -fn h2_recording_closes_to_always_then_added_scope() { - let bindings = fixture_bindings( - "tools.bind('search', 'search the web'); \ - tools.bind('fetch', 'fetch a page'); \ - tools.always('search')", +fn section_scope_closes_to_always_then_added() { + let bindings = fixture_set( + &[ + ("search", "search the web", "search"), + ("fetch", "fetch a page", "fetch"), + ], + &["search"], ); let prologue = program("tools.add({'fetch', 'search'})"); let mut vm = @@ -1008,8 +984,8 @@ fn h2_recording_closes_to_always_then_added_scope() { vm.inject_host("", &json!({}), &fresh_access()) .expect("host must inject"); run_scalar(&vm, &prologue, &NullObserver::default(), "Section") - .expect("H2 additions must record"); - let (bindings, runtime) = vm.tool_bag_handles(); + .expect("section additions must record"); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); assert_eq!( @@ -1019,39 +995,13 @@ fn h2_recording_closes_to_always_then_added_scope() { } #[test] -fn h2_add_accepts_tool_objects_and_arrays() { - let resolver = |description: &str| { - Ok(ToolId::parse(&format!( - "fixtures/tools/{}", - if description == "search the web" { - "search" - } else { - "fetch" - }, - )) - .expect("valid id")) - }; - let h1_error = execute_live_tool_binds( - &program( - "local search = tools.bind('search', 'search the web'); \ - tools.add(search)", - ), - &resolver, - EXECUTION, - &NullObserver::default(), - "Prompt", - ) - .expect_err("tools.add must stay H2-only even when passed a Tool object"); - assert!( - h1_error - .to_string() - .contains("tools.add is only available during H2 recording"), - "H1 tools.add(Tool) must report the phase error, not a type error: {h1_error}" - ); - - let bindings = fixture_bindings( - "search = tools.bind('search', 'search the web'); \ - fetch = tools.bind('fetch', 'fetch a page')", +fn tools_add_accepts_tool_objects_and_arrays() { + let bindings = fixture_set( + &[ + ("search", "search the web", "search"), + ("fetch", "fetch a page", "fetch"), + ], + &[], ); let prologue = program( "tools.add(search); \ @@ -1065,7 +1015,7 @@ fn h2_add_accepts_tool_objects_and_arrays() { .expect("host must inject"); run_scalar(&vm, &prologue, &NullObserver::default(), "Section") .expect("tools.add must accept Tool objects, strings, and arrays"); - let (bindings, runtime) = vm.tool_bag_handles(); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); assert_eq!( @@ -1077,9 +1027,12 @@ fn h2_add_accepts_tool_objects_and_arrays() { #[test] fn empty_add_is_a_no_op_and_failed_bulk_add_is_atomic() { - let bindings = fixture_bindings( - "tools.bind('search', 'search the web'); \ - tools.bind('fetch', 'fetch a page')", + let bindings = fixture_set( + &[ + ("search", "search the web", "search"), + ("fetch", "fetch a page", "fetch"), + ], + &[], ); let prologue = program( "tools.add(); \ @@ -1094,7 +1047,7 @@ fn empty_add_is_a_no_op_and_failed_bulk_add_is_atomic() { .expect("host must inject"); run_scalar(&vm, &prologue, &NullObserver::default(), "Section") .expect("caught failed add must not poison recording"); - let (bindings, runtime) = vm.tool_bag_handles(); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); assert_eq!( @@ -1106,7 +1059,7 @@ fn empty_add_is_a_no_op_and_failed_bulk_add_is_atomic() { #[test] fn add_rejects_misshapen_override_arguments() { - let bindings = fixture_bindings("tools.bind('search', 'search the web')"); + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); let prologue = program( "local ok, err = pcall(tools.add, {'search'}, 'bulk override'); \ if ok or not string.find(tostring(err), 'array form takes no override') then \ @@ -1129,7 +1082,7 @@ fn add_rejects_misshapen_override_arguments() { .expect("host must inject"); run_scalar(&vm, &prologue, &NullObserver::default(), "Section") .expect("rejected override forms must not poison recording"); - let (bindings, runtime) = vm.tool_bag_handles(); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); assert_eq!( @@ -1146,44 +1099,27 @@ fn add_rejects_misshapen_override_arguments() { } #[test] -fn tool_operations_enforce_their_lifecycle_phase_even_when_captured() { - let bindings = fixture_bindings("tools.bind('search', 'search the web')"); +fn unknown_scoped_alias_fails_before_scope_closure() { + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); let mut vm = section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") .expect("captured bindings must install"); vm.inject_host("", &json!({}), &fresh_access()) .expect("host must inject"); - let error = run_scalar( &vm, - &program("tools.bind('other', 'fetch a page')"), + &program("tools.add('missing')"), &NullObserver::default(), "Section", ) - .expect_err("current H2 table must reject bind"); + .expect_err("only bound aliases may enter the section scope"); assert!( error .to_string() - .contains("only available during live H1 execution") + .contains("tools.add alias \"missing\" is not a bound tool slot"), + "the error names the unbound alias: {error}" ); -} - -#[test] -fn unknown_h2_alias_fails_before_scope_closure() { - let bindings = fixture_bindings("tools.bind('search', 'search the web')"); - let mut vm = - section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Section") - .expect("captured bindings must install"); - vm.inject_host("", &json!({}), &fresh_access()) - .expect("host must inject"); - let error = run_scalar( - &vm, - &program("tools.add('missing')"), - &NullObserver::default(), - "Section", - ) - .expect_err("only declared aliases may enter H2 scope"); - assert!(error.to_string().contains("not declared")); + vm.teardown(&NullObserver::default(), "Section"); } #[test] @@ -1313,8 +1249,8 @@ fn section_vm_host_injection_bypasses_shared_global_metatables() { ); let mut vm = SectionVm::new_for_section( &test_nonce(), - &bindings, - &ModelSet::default(), + &shared_set(bindings), + &Arc::new(Mutex::new(ModelSet::default())), EXECUTION, &NullObserver::default(), "Test", @@ -1600,8 +1536,8 @@ fn shared_replay_sees_the_tables_but_not_the_bare_alias_globals() { ); let mut vm = SectionVm::new_for_section( &test_nonce(), - &bindings, - &ModelSet::default(), + &shared_set(bindings), + &Arc::new(Mutex::new(ModelSet::default())), EXECUTION, &NullObserver::default(), "Test", @@ -1628,7 +1564,7 @@ fn shared_replay_sees_the_tables_but_not_the_bare_alias_globals() { .as_deref(), Some("userdata") ); - let (bindings, runtime) = vm.tool_bag_handles(); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), @@ -1658,8 +1594,8 @@ fn shared_functions_resolve_host_globals_when_called_from_a_later_chunk() { ); let mut vm = SectionVm::new_for_section( &test_nonce(), - &bindings, - &ModelSet::default(), + &shared_set(bindings), + &Arc::new(Mutex::new(ModelSet::default())), EXECUTION, &NullObserver::default(), "Test", @@ -1686,7 +1622,7 @@ fn shared_functions_resolve_host_globals_when_called_from_a_later_chunk() { .as_deref(), Some("search") ); - let (bindings, runtime) = vm.tool_bag_handles(); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), @@ -2372,14 +2308,13 @@ async fn a_pre_cancelled_run_aborts_a_tight_loop_promptly() { } #[test] -fn add_without_declarations_fails_as_undeclared_in_a_chunk() { - let error = - run("tools.add('web_search')", "").expect_err("an undeclared alias must fail loudly"); +fn add_without_declarations_fails_as_unbound_in_a_chunk() { + let error = run("tools.add('web_search')", "").expect_err("an unbound alias must fail loudly"); assert!( error .to_string() - .contains("tools.add alias \"web_search\" was not declared by tools.bind"), - "the error must name the undeclared alias: {error}" + .contains("tools.add alias \"web_search\" is not a bound tool slot"), + "the error must name the unbound alias: {error}" ); } @@ -2395,29 +2330,17 @@ fn add_without_declarations_fails_in_a_prologue_without_a_shared_library() { &NullObserver::default(), "Test", ) - .expect_err("an undeclared alias must fail loudly"); + .expect_err("an unbound alias must fail loudly"); assert!( - error.to_string().contains("not declared by tools.bind"), - "the error must report the missing declaration: {error}" + error.to_string().contains("is not a bound tool slot"), + "the error must report the missing slot: {error}" ); vm.teardown(&NullObserver::default(), "Test"); } #[test] -fn add_with_empty_frozen_bindings_fails_as_undeclared() { - let shared = program("function helper() return 'no declarations' end"); - let resolver = |description: &str| -> Result { - panic!("a declaration-free program must not resolve {description:?}") - }; - let bindings = execute_live_tool_binds( - &shared, - &resolver, - EXECUTION, - &NullObserver::default(), - "Prompt", - ) - .expect("a bind-free H1 program must execute"); - assert!(bindings.bindings().is_empty()); +fn add_with_empty_frozen_bindings_fails_as_unbound() { + let bindings = ToolSet::default(); let mut vm = section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Test") .expect("empty captured bindings must install"); vm.inject_host("", &json!({}), &fresh_access()) @@ -2428,17 +2351,17 @@ fn add_with_empty_frozen_bindings_fails_as_undeclared() { &NullObserver::default(), "Test", ) - .expect_err("an undeclared alias must fail loudly"); + .expect_err("an unbound alias must fail loudly"); assert!( - error.to_string().contains("not declared by tools.bind"), - "the error must report the missing declaration: {error}" + error.to_string().contains("is not a bound tool slot"), + "the error must report the missing slot: {error}" ); vm.teardown(&NullObserver::default(), "Test"); } #[test] fn add_with_an_override_argument_records_the_model_description() { - let bindings = fixture_bindings("tools.bind('search', 'search the web')"); + let bindings = fixture_set(&[("search", "search the web", "search")], &[]); let mut vm = section_vm_with_bindings(&bindings, EXECUTION, &NullObserver::default(), "Test") .expect("captured bindings must install"); vm.inject_host("", &json!({}), &fresh_access()) @@ -2450,7 +2373,7 @@ fn add_with_an_override_argument_records_the_model_description() { "Test", ) .expect("a description passed to tools.add is the model-facing override"); - let (bindings, runtime) = vm.tool_bag_handles(); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); assert_eq!( scope[0].model_description(), @@ -2466,7 +2389,7 @@ fn a_section_vm_without_declarations_snapshots_to_an_empty_scope() { .expect("VM must build"); vm.inject_host("", &json!({}), &fresh_access()) .expect("host values must inject"); - let (bindings, runtime) = vm.tool_bag_handles(); + let (bindings, runtime) = vm.tool_bag_handles().expect("the bag snapshots"); let scope = current_tool_bindings(&bindings, &runtime).expect("an empty scope must snapshot"); assert!(scope.is_empty()); vm.teardown(&NullObserver::default(), "Test"); diff --git a/crates/promptforge-lua/src/tools/mod.rs b/crates/promptforge-lua/src/tools/mod.rs index 5dc14b74..0c7d0b3f 100644 --- a/crates/promptforge-lua/src/tools/mod.rs +++ b/crates/promptforge-lua/src/tools/mod.rs @@ -1,11 +1,15 @@ -//! The `tools` namespace: declaration, scoping, invocation, and counts. +//! The `tools` namespace: scoping, invocation, and counts. //! //! One Lua table carries every tool operation, mirroring the `models.*` -//! namespacing of model operations: `bind` and `always` declare during -//! live H1 (forbidden stubs here), `add` and `add_local` scope tools into -//! the section, `call` dispatches a bound tool by alias or Tool object -//! (installed by the coroutine shim prelude, since dispatch suspends), and -//! `calls` is the read-only per-alias dispatch counter surface. The +//! namespacing of model operations. Binding is the frontmatter's: the run's +//! filled slots arrive in the shared [`ToolSet`], and the table scopes among +//! them by alias - `add` scopes aliases into the section, `always` parks a +//! prompt-wide alias (conventionally from H1, not privileged to it), +//! `add_local` registers a prompt-author Lua function as a tool, `call` +//! dispatches a bound tool by alias or Tool object (installed by the +//! coroutine shim prelude, since dispatch suspends), and `calls` is the +//! read-only per-alias dispatch counter surface. Only filled slots are +//! visible: scoping or advertising an unfilled alias is a hard error. The //! installation logic lives here, out of the VM driver; the VM only calls //! the installers in setup order. @@ -14,9 +18,9 @@ use std::sync::{Arc, Mutex}; use mlua::{Function, Lua, MultiValue, Table, Value, Variadic}; use promptforge_model_client::client::ToolSchema; +use crate::alias::validate_alias; use crate::error::{Error, Result}; use crate::handles::{ToolBinding, ToolSet}; -use crate::live::validate_alias; use crate::scope::{ToolCallCounts, ToolRuntime}; use crate::vm::LocalTools; @@ -29,6 +33,13 @@ pub(crate) use decode::tool_alias; use decode::{add_local_params_schema, collect_tools_add_entries}; +/// Locks the run's shared tool set, mapping a poisoned lock to the Lua +/// boundary error every host callback uses. +fn lock_tools(set: &Mutex) -> mlua::Result> { + set.lock() + .map_err(|_| mlua::Error::external("tool set mutex was poisoned")) +} + /// Installs the read-only `tools.calls` counter table over `counts`; /// `declared` feeds the unknown-key diagnostic. /// @@ -60,7 +71,7 @@ pub(crate) fn install_lua_tool_calls( "tools.calls: {key:?} has no seeded count; \ seeded aliases: {seeded:?}{}", if declared_unseeded { - " (alias was declared by tools.bind but neither added to \ + " (alias is a bound tool slot but was neither added to \ this section's scope nor dispatched by tools.call)" } else if seeded.is_empty() { "" @@ -89,7 +100,7 @@ pub(crate) fn install_lua_tool_calls( /// Installs `tools.calls` as a read-only Lua table backed by a fresh /// [`ToolCallCounts`]. Each seeded alias reads its live count; indexing an /// unseeded key is a hard error that names the bad key and lists the seeded -/// set. When the key was declared by `tools.bind` but never seeded - neither +/// set. When the key names a bound tool slot but was never seeded - neither /// scoped into the section nor dispatched by a script `tools.call` - the /// diagnostic says so. /// @@ -113,7 +124,8 @@ pub(crate) fn install_tool_call_counts( Ok(counts) } -/// Installs the H2 tool declaration and local-tool APIs into one section VM. +/// Installs the tool scoping and local-tool APIs into one section VM (H1 +/// included: there is one install path for every section). /// /// The suspending `tools.call` is not installed here: yield cannot cross /// the Rust callback boundary, so the coroutine shim prelude installs it on @@ -122,54 +134,43 @@ pub(crate) fn install_tool_call_counts( /// # Errors /// Returns [`Error::Lua`] if a Lua table or callback cannot be created or /// installed. -pub(crate) fn install_h2_tools( +pub(crate) fn install_tools( lua: &Lua, globals: &Table, - bindings: &ToolSet, + set: &Arc>, runtime: &Arc>, local_tools: &LocalTools, ) -> Result<()> { let tools = lua.create_table().map_err(Error::lua)?; - for name in ["bind", "always"] { - let operation = name; - let forbidden = lua - .create_function(move |_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external(format!( - "tools.{operation} is only available during live H1 execution" - ))) - }) - .map_err(Error::lua)?; - tools.set(name, forbidden).map_err(Error::lua)?; - } - let frozen = bindings.clone(); + let frozen = Arc::clone(set); let state = Arc::clone(runtime); let add = lua .create_function(move |_, args: Variadic| { let entries = collect_tools_add_entries(args)?; + { + let set = lock_tools(&frozen)?; + for entry in &entries { + validate_alias(&entry.alias).map_err(mlua::Error::external)?; + if set.binding(&entry.alias).is_none() { + return Err(mlua::Error::external(format!( + "tools.add alias {:?} is not a bound tool slot", + entry.alias + ))); + } + } + } let mut state = state .lock() .map_err(|_| mlua::Error::external("tool declaration runtime was poisoned"))?; - for entry in &entries { - validate_alias(&entry.alias).map_err(mlua::Error::external)?; - if frozen.binding(&entry.alias).is_none() { - return Err(mlua::Error::external(format!( - "tools.add alias {:?} was not declared by tools.bind", - entry.alias - ))); - } - } + let set = lock_tools(&frozen)?; for entry in entries { if let Some(description) = entry.description_override { state .description_overrides .insert(entry.alias.clone(), description); } - if frozen - .always - .iter() - .any(|existing| existing == &entry.alias) - { + if set.always.iter().any(|existing| existing == &entry.alias) { continue; } if !state.added.iter().any(|existing| existing == &entry.alias) { @@ -181,15 +182,45 @@ pub(crate) fn install_h2_tools( .map_err(Error::lua)?; tools.set("add", add).map_err(Error::lua)?; - let declared = bindings.clone(); + let frozen = Arc::clone(set); + let always = lua + .create_function( + move |_, (alias, model_description): (String, Option)| -> mlua::Result<()> { + validate_alias(&alias).map_err(mlua::Error::external)?; + let mut set = lock_tools(&frozen)?; + let Some(binding) = set + .bindings + .iter_mut() + .find(|binding| binding.alias == alias) + else { + return Err(mlua::Error::external(format!( + "tools.always alias {alias:?} is not a bound tool slot" + ))); + }; + if let Some(model_description) = model_description { + binding.model_description = Some(model_description); + } + // Idempotent under the shared-library replay: every section + // re-runs the library, so naming the same alias again is a + // no-op. + if !set.always.iter().any(|existing| existing == &alias) { + set.always.push(alias); + } + Ok(()) + }, + ) + .map_err(Error::lua)?; + tools.set("always", always).map_err(Error::lua)?; + + let declared = Arc::clone(set); let local = local_tools.clone(); let add_local_fn = lua .create_function( move |lua, (alias, description, params, handler): (String, String, Table, Function)| { validate_alias(&alias).map_err(mlua::Error::external)?; - if declared.binding(&alias).is_some() { + if lock_tools(&declared)?.binding(&alias).is_some() { return Err(mlua::Error::external(format!( - "tools.add_local alias {alias:?} duplicates a declared tool alias" + "tools.add_local alias {alias:?} duplicates a bound tool slot" ))); } if local.contains(&alias).map_err(mlua::Error::external)? { diff --git a/crates/promptforge-lua/src/tools/tests.rs b/crates/promptforge-lua/src/tools/tests.rs index 75b0cad9..7fe815ca 100644 --- a/crates/promptforge-lua/src/tools/tests.rs +++ b/crates/promptforge-lua/src/tools/tests.rs @@ -5,7 +5,7 @@ use shared_promptforge_api::untrusted::GuardNonce; use super::decode::{add_local_params_schema, collect_tools_add_entries, tool_alias}; use super::userdata::LuaToolHandle; -use super::{install_h2_tools, install_tool_call_counts}; +use super::{install_tool_call_counts, install_tools}; use crate::handles::{LuaFanoutResult, ToolSet}; use crate::scope::ToolRuntime; use crate::{SectionVm, ToolBinding}; @@ -144,51 +144,48 @@ fn add_local_params_schema_rejects_an_unsupported_type() { ); } -/// Installs the H2 namespace on a fresh VM and returns it. -fn lua_with_h2_tools() -> Lua { +/// Installs the tools namespace on a fresh VM and returns it. +fn lua_with_tools() -> Lua { let lua = Lua::new(); let globals = lua.globals(); let runtime = Arc::new(Mutex::new(ToolRuntime { added: Vec::new(), description_overrides: std::collections::BTreeMap::default(), })); - install_h2_tools( + install_tools( &lua, &globals, - &ToolSet::default(), + &Arc::new(Mutex::new(ToolSet::default())), &runtime, &crate::vm::LocalTools::default(), ) - .expect("the H2 tools install cannot fail on a fresh VM"); + .expect("the tools install cannot fail on a fresh VM"); lua } #[test] -fn the_h2_namespace_carries_declaration_scoping_and_no_call_yet() { +fn the_tools_namespace_carries_scoping_and_no_bind_or_call() { // `call` is absent here on purpose: it suspends, so the coroutine shim // prelude installs it - this table carries exactly the non-suspending - // operations. - let lua = lua_with_h2_tools(); - let (has_add, has_add_local, call_is_nil): (bool, bool, bool) = lua + // operations. `bind` is gone entirely: binding is the frontmatter's. + let lua = lua_with_tools(); + let (has_add, has_add_local, has_always, call_is_nil, bind_is_nil): ( + bool, + bool, + bool, + bool, + bool, + ) = lua .load( "return type(tools.add) == 'function', \ type(tools.add_local) == 'function', \ - tools.call == nil", + type(tools.always) == 'function', \ + tools.call == nil, \ + tools.bind == nil", ) .eval() .expect("the namespace probe evaluates"); - assert!(has_add && has_add_local && call_is_nil); - let bind_error = lua - .load("local ok, err = pcall(tools.bind, 'x', 'y'); return not ok, tostring(err)") - .eval::<(bool, String)>() - .expect("the bind stub raises"); - assert!(bind_error.0); - assert!( - bind_error - .1 - .contains("tools.bind is only available during live H1 execution"), - "the H1-only operations stay forbidden in a section: {bind_error:?}" - ); + assert!(has_add && has_add_local && has_always && call_is_nil && bind_is_nil); } #[test] @@ -212,7 +209,7 @@ fn the_shim_prelude_installs_tools_call_and_no_bare_global() { #[test] fn tool_call_counts_seed_read_and_reject_unknown_keys() { - let lua = lua_with_h2_tools(); + let lua = lua_with_tools(); let bound = ToolSet::for_test( vec![ToolBinding::for_test( "echo", diff --git a/crates/promptforge-lua/src/tools/userdata.rs b/crates/promptforge-lua/src/tools/userdata.rs index dc983ebc..9050c612 100644 --- a/crates/promptforge-lua/src/tools/userdata.rs +++ b/crates/promptforge-lua/src/tools/userdata.rs @@ -1,19 +1,19 @@ -//! Inspectable Tool object returned by Lua `tools.bind`. +//! Inspectable Tool object for a bound slot. //! //! Presentation only: the userdata exposes a bound tool's fields to Lua and //! serves as the leading handle argument to `tools.call`. Authors read //! `.name`, `.description`, `.parameters`, `.wire_name`, and `.untrusted`. //! The object is frozen and methodless (A9): model-facing description -//! overrides are positional arguments to `tools.bind` / `tools.always` / -//! `tools.add`, never assignments on this handle, and invocation is -//! namespace-only through `tools.call(alias_or_tool, arguments)`. Existing +//! overrides are positional arguments to `tools.always` / `tools.add`, +//! never assignments on this handle, and invocation is namespace-only +//! through `tools.call(alias_or_tool, arguments)`. Existing //! callers that ignore the return value keep working. use mlua::{LuaSerdeExt, MetaMethod, UserData, UserDataFields, UserDataMethods, Value}; use serde_json::{Value as Json, json}; -use shared_promptforge_api::tools::{Tool, ToolId}; +use shared_promptforge_api::tools::ToolId; -/// Inspectable Tool object returned by Lua `tools.bind`. +/// Inspectable Tool object for a bound slot. #[derive(Debug, Clone, PartialEq)] pub(crate) struct LuaToolHandle { name: String, @@ -43,23 +43,6 @@ impl LuaToolHandle { } } - /// Builds a handle from a live tool and its prompt-local binding metadata. - pub(crate) fn from_live_binding( - alias: impl Into, - description: impl Into, - tool: &dyn Tool, - ) -> Self { - Self { - name: alias.into(), - description: description.into(), - parameters: tool.parameters_schema(), - wire_name: tool.wire_name().to_owned(), - // Trust is now carried per-call in `ToolOutput`, not a static - // per-tool flag; the executor wraps untrusted results at dispatch. - untrusted: false, - } - } - /// Returns the prompt-local alias. #[must_use] pub(crate) fn name(&self) -> &str { diff --git a/crates/promptforge-lua/src/vm.rs b/crates/promptforge-lua/src/vm.rs index df90e63c..bbfa94e2 100644 --- a/crates/promptforge-lua/src/vm.rs +++ b/crates/promptforge-lua/src/vm.rs @@ -5,10 +5,10 @@ use super::{ ModelBinding, ModelRuntime, ModelSet, ModelView, ModelsInferHook, MultiValue, Mutex, Observer, Ordering, ProseState, Result, StdLib, Thread, ThreadStatus, ToolBinding, ToolCallCounts, ToolRuntime, ToolSet, Value, detail, guarded_var, harden, install_compactors, - install_h2_models, install_h2_tools, install_instruction_budget, install_log, install_messages, + install_instruction_budget, install_log, install_messages, install_models, install_shim_prelude, install_store_table, - install_tool_call_counts as install_tool_call_counts_impl, install_untrusted, log_byte_budget, - resolve_section_target, scalar_return, seal_sys, var_to_json, + install_tool_call_counts as install_tool_call_counts_impl, install_tools, install_untrusted, + log_byte_budget, resolve_section_target, scalar_return, seal_sys, var_to_json, }; use promptforge_model_client::client::ToolSchema; @@ -61,8 +61,13 @@ pub(crate) fn pack_sequence( pub struct SectionVm { execution: String, lua: Lua, - bound_tools: ToolSet, - bound_models: ModelSet, + /// The run's shared tool set: the frontmatter's filled slots plus the + /// prompt-wide `always` aliases. Shared with the run, not snapshotted: + /// `tools.always` is a prompt-wide fact that later sections must see. + bound_tools: Arc>, + /// The run's shared model set: the frontmatter's filled roles plus the + /// prompt-wide default. Shared for the same reason (`models.default`). + bound_models: Arc>, /// The section's tool-addition runtime, read by the executor's scope path. pub tool_runtime: Arc>, /// The section's model-selection runtime, read by the executor's scope path. @@ -213,10 +218,10 @@ impl SectionVm { /// type-level docs). The VM retains `execution` for every later /// lifecycle report. /// - /// The VM carries no frozen tool bindings, so the validating `tools.add` - /// installed by [`inject_host`](Self::inject_host) rejects every alias as - /// undeclared: a prompt without `tools.bind` declarations cannot scope - /// tools. + /// The VM shares the run's (possibly empty) tool and model sets, so the + /// validating `tools.add` installed by + /// [`inject_host_with_var`](Self::inject_host_with_var) rejects every + /// alias as unbound until prepare's filled slots arrive with the sets. /// /// # Errors /// Returns [`Error::Lua`] if the VM cannot be built or hardened. @@ -251,8 +256,8 @@ impl SectionVm { let mut vm = Self { execution: execution.to_owned(), lua, - bound_tools: ToolSet::default(), - bound_models: ModelSet::default(), + bound_tools: Arc::new(Mutex::new(ToolSet::default())), + bound_models: Arc::new(Mutex::new(ModelSet::default())), tool_runtime: Arc::new(Mutex::new(ToolRuntime { added: Vec::new(), description_overrides: BTreeMap::new(), @@ -280,28 +285,30 @@ impl SectionVm { } Ok(vm) } - /// Creates a section VM carrying the prompt's frozen tool and model bindings. + /// Creates a section VM sharing the run's tool and model sets. /// - /// The bindings back the validating `tools`/`models` tables that - /// [`inject_host_with_var`](Self::inject_host_with_var) installs, and the + /// The sets are the run's own handles, not snapshots: the frontmatter's + /// filled slots back the validating `tools`/`models` tables that + /// [`inject_host_with_var`](Self::inject_host_with_var) installs and the /// bare alias globals that /// [`install_captured_bindings`](Self::install_captured_bindings) - /// installs after the shared replay. H1 code is never replayed into a - /// section VM. + /// installs after the shared replay, and the prompt-wide facts a section + /// records (`tools.always`, `models.default`) land where every later + /// section sees them. H1 is section 0 on this same path. /// /// # Errors /// Returns [`Error::Lua`] if the VM cannot be built or hardened. pub fn new_for_section( nonce: &GuardNonce, - tools: &ToolSet, - models: &ModelSet, + tools: &Arc>, + models: &Arc>, execution: &str, observer: &dyn Observer, section: &str, ) -> Result { let mut vm = Self::new(nonce, execution, observer, section)?; - vm.bound_tools = tools.clone(); - vm.bound_models = models.clone(); + vm.bound_tools = Arc::clone(tools); + vm.bound_models = Arc::clone(models); Ok(vm) } @@ -360,32 +367,48 @@ impl SectionVm { /// Installs the captured tool and model alias globals. /// - /// Each frozen binding becomes a bare global holding its handle userdata. + /// Each bound slot becomes a bare global holding its handle userdata. /// The engine calls this after [`replay_shared`](Self::replay_shared), so /// a declared alias wins over a same-named shared global; the raw install /// also bypasses any metatable the shared library set on `_G`. /// /// # Errors - /// Returns [`Error::Lua`] if a handle cannot be created or installed. + /// Returns [`Error::Lua`] if a handle cannot be created or installed, or + /// a shared set's mutex is poisoned. pub fn install_captured_bindings(&self) -> Result<()> { let globals = self.lua.globals(); - for binding in self.bound_tools.bindings() { - let handle = - LuaToolHandle::from_binding(binding.alias(), binding.description(), binding.id()); - let userdata = self.lua.create_userdata(handle).map_err(Error::lua)?; - globals - .raw_set(binding.alias(), userdata) - .map_err(Error::lua)?; + { + let tools = self + .bound_tools + .lock() + .map_err(|_| Error::Lua("tool set mutex was poisoned".to_owned()))?; + for binding in tools.bindings() { + let handle = LuaToolHandle::from_binding( + binding.alias(), + binding.description(), + binding.id(), + ); + let userdata = self.lua.create_userdata(handle).map_err(Error::lua)?; + globals + .raw_set(binding.alias(), userdata) + .map_err(Error::lua)?; + } } - for binding in self.bound_models.bindings() { - // Handles are plain frozen userdata in every mode: invocation is - // namespace-only (`models.infer(handle, prompt)`), so no - // shim-wrapped proxy is needed. - let handle = LuaModelHandle::from_binding(binding); - let userdata = self.lua.create_userdata(handle).map_err(Error::lua)?; - globals - .raw_set(binding.alias(), userdata) - .map_err(Error::lua)?; + { + let models = self + .bound_models + .lock() + .map_err(|_| Error::Lua("model set mutex was poisoned".to_owned()))?; + for binding in models.bindings() { + // Handles are plain frozen userdata in every mode: invocation is + // namespace-only (`models.infer(handle, prompt)`), so no + // shim-wrapped proxy is needed. + let handle = LuaModelHandle::from_binding(binding); + let userdata = self.lua.create_userdata(handle).map_err(Error::lua)?; + globals + .raw_set(binding.alias(), userdata) + .map_err(Error::lua)?; + } } Ok(()) } @@ -462,14 +485,14 @@ impl SectionVm { } let var = guarded_var(&self.lua, initial_var)?; globals.raw_set("var", var).map_err(Error::lua)?; - install_h2_tools( + install_tools( &self.lua, &globals, &self.bound_tools, &self.tool_runtime, &self.local_tools, )?; - install_h2_models( + install_models( &self.lua, &globals, &self.bound_models, @@ -631,31 +654,6 @@ impl SectionVm { .map_err(Error::lua) } - /// Installs `call`, `jump`, `fanout`, and `list_from_section` as - /// stubs that fail with a clear error, for the live H1 VM only. - /// - /// H1 runs before any section exists, so the real control globals can - /// never operate there; without stubs a call dies with Lua's stock - /// nil-call error, which names no cause. - /// - /// # Errors - /// Returns [`Error::Lua`] if any global cannot be installed. - pub fn install_h1_control_stubs(&self) -> Result<()> { - let globals = self.lua.globals(); - for name in ["call", "jump", "fanout", "list_from_section"] { - let stub = self - .lua - .create_function(move |_, _: MultiValue| -> mlua::Result<()> { - Err(mlua::Error::external(format!( - "{name} is only available in sections (## headings); H1 runs before sections exist" - ))) - }) - .map_err(Error::lua)?; - globals.raw_set(name, stub).map_err(Error::lua)?; - } - Ok(()) - } - /// Replaces the sealed Lua `sys` global after scope close. /// /// Host injection must have run first. Used to expose `sys.model` once the @@ -841,7 +839,7 @@ impl SectionVm { /// Installs `tools.calls` as a read-only Lua table backed by a fresh /// [`ToolCallCounts`]. Each seeded alias reads its live count; indexing /// an unseeded key is a hard error that names the bad key and lists the - /// seeded set. When the key was declared by `tools.bind` but never + /// seeded set. When the key names a bound tool slot but was never /// seeded - neither scoped into the section nor dispatched by a script /// `tools.call` - the diagnostic says so. /// @@ -852,32 +850,54 @@ impl SectionVm { /// increment it. /// /// # Errors - /// Returns [`Error::Lua`] when installing the `tools.calls` index fails. + /// Returns [`Error::Lua`] when installing the `tools.calls` index fails + /// or the shared tool set's mutex is poisoned. pub fn install_tool_call_counts(&self, bindings: &[ToolBinding]) -> Result { - install_tool_call_counts_impl(&self.lua, &self.bound_tools, bindings) + let declared = self + .bound_tools + .lock() + .map_err(|_| Error::Lua("tool set mutex was poisoned".to_owned()))? + .clone(); + install_tool_call_counts_impl(&self.lua, &declared, bindings) } - /// Returns frozen tool bindings and the live H2 addition runtime. + /// Returns a snapshot of the shared tool set and the live section + /// addition runtime. /// /// `#[doc(hidden)]`: a cross-crate seam for `promptforge-api`'s executor /// tests, not host API. + /// + /// # Errors + /// Returns [`Error::Lua`] if the shared tool set's mutex is poisoned. #[doc(hidden)] - #[must_use] - pub fn tool_bag_handles(&self) -> (ToolSet, Arc>) { - (self.bound_tools.clone(), Arc::clone(&self.tool_runtime)) + pub fn tool_bag_handles(&self) -> Result<(ToolSet, Arc>)> { + let tools = self + .bound_tools + .lock() + .map_err(|_| Error::Lua("tool set mutex was poisoned".to_owned()))? + .clone(); + Ok((tools, Arc::clone(&self.tool_runtime))) } - /// Returns frozen model bindings and the live H2 selection runtime. + /// Returns a snapshot of the shared model set and the live section + /// selection runtime. /// /// 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-api`'s tests, /// not host API. + /// + /// # Errors + /// Returns [`Error::Lua`] if the shared model set's mutex is poisoned. #[doc(hidden)] - #[must_use] - pub fn model_bag_handles(&self) -> (ModelSet, Arc>) { - (self.bound_models.clone(), Arc::clone(&self.model_runtime)) + pub fn model_bag_handles(&self) -> Result<(ModelSet, Arc>)> { + let models = self + .bound_models + .lock() + .map_err(|_| Error::Lua("model set mutex was poisoned".to_owned()))? + .clone(); + Ok((models, Arc::clone(&self.model_runtime))) } /// Borrows the inner Lua state, so the shim installs and the @@ -1201,8 +1221,8 @@ pub(crate) struct LuaOutcome { /// is always present (a host capability, not a scoped tool). /// /// The `tools` table is the same validating one every section VM installs, -/// with no frozen bindings: a chunk that calls `tools.add(...)` fails loudly -/// because no alias was declared by `tools.bind`. +/// over an empty shared set: a chunk that calls `tools.add(...)` fails loudly +/// because no alias is bound. /// /// # Errors /// Returns [`Error::Lua`] if the sandbox cannot be built, `sys`/`var`/`store` diff --git a/crates/promptforge-model-client/src/model/options.rs b/crates/promptforge-model-client/src/model/options.rs index 0a11fe91..1c0d8a14 100644 --- a/crates/promptforge-model-client/src/model/options.rs +++ b/crates/promptforge-model-client/src/model/options.rs @@ -127,6 +127,10 @@ pub struct ModelBinding { id: ModelId, invocation: ModelInvocation, context: NonZeroU32, + /// The bound role's full keyword set (the closed frontmatter + /// vocabulary, kebab-case), exposed on the Lua handle as + /// `capabilities`. Empty for bindings built outside a role fill. + capabilities: Vec, } impl ModelBinding { @@ -149,9 +153,23 @@ impl ModelBinding { id, invocation, context, + capabilities: Vec::new(), } } + /// Records the bound role's keyword set, exposed on the Lua handle. + #[must_use] + pub fn with_capabilities(mut self, capabilities: Vec) -> Self { + self.capabilities = capabilities; + self + } + + /// Returns the bound role's keyword set. + #[must_use] + pub fn capabilities(&self) -> &[String] { + &self.capabilities + } + /// Returns the exact prompt-local alias. #[must_use] pub fn alias(&self) -> &str { diff --git a/crates/promptforge-parser/src/contract.rs b/crates/promptforge-parser/src/contract.rs index 6ec86fec..60f2137e 100644 --- a/crates/promptforge-parser/src/contract.rs +++ b/crates/promptforge-parser/src/contract.rs @@ -33,9 +33,8 @@ pub use models::{ModelKeyword, ModelRole, ModelRoles}; /// /// Aliases are the only names a model ever sees - tool slot aliases, model /// labels, and args field names are all prompt-local and never global -/// names. (The same rule lives in `promptforge-lua`'s live binding and -/// model decode paths; the survey's consolidation note applies when those -/// files are touched.) +/// names. (The same rule lives in `promptforge-lua`'s `alias` module, the +/// run-time counterpart to this parse-time check.) fn is_valid_alias(alias: &str) -> bool { let bytes = alias.as_bytes(); (1..=64).contains(&bytes.len()) diff --git a/crates/shared-promptforge-api/src/capabilities.rs b/crates/shared-promptforge-api/src/capabilities.rs index 78f0f0d1..9e2f05bc 100644 --- a/crates/shared-promptforge-api/src/capabilities.rs +++ b/crates/shared-promptforge-api/src/capabilities.rs @@ -3,7 +3,7 @@ //! A capability is the activation unit: code that runs at run setup and //! makes services available to the run. Capabilities are delivered in packs //! (crates now, DLLs via adapters later) and identified by a 2-segment -//! [`GlobalName`](crate::names::GlobalName) - kind is encoded by arity, so a +//! [`GlobalName`] - kind is encoded by arity, so a //! capability id is `namespace/pack` and every tool it contributes lives //! under `namespace/pack/name`. At prepare time the executor activates each //! declared capability by calling [`Capability::create`] with the run's diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 72870bc5..99341d1d 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -986,7 +986,7 @@ Prepare assembles contributed tools into the run's `ToolCatalog` in declaration -### Step 13: Lua surface consolidation +### Step 13: Lua surface consolidation [completed] [completed] - Component: binding From 2109f209ecc9cc5e4c3ed729ba46ee8e6f72a5ee Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 00:28:17 -0700 Subject: [PATCH 14/30] Add the args/argv surface and its substitution namespace The run's argument string gains a parsed form alongside the raw one. A structured declaration parses it as JSON and a parse failure or null reads as nil, so a prompt can detect malformed input; a default declaration instead wraps the interface prose, with the empty string present rather than absent. The first section may assign a repaired value, and when it completes the value freezes: every later section reads the repair, and any assignment elsewhere raises. Templates gain a matching namespace that renders the whole value or follows a dotted path, treating a nil value as a hard error. A call chain with explicit input re-derives the parsed form from its own string, while a chain without input inherits the repaired value. - `crates/promptforge-lua/src/argv.rs`: New module installing the `argv` global in two modes: writable for the H1 pass, frozen for every other section. The freeze rides the globals metatable with recorded delegates so it composes with the prose guard, and table values deep-freeze behind proxies that reject writes at any depth. - `Argv`: The doc-hidden cross-crate enum carries the install mode and value into host injection, and `SectionVm::argv_json` reads the H1 value back as JSON, rejecting functions, userdata, and threads. - `ArgsDecl`: A private `implicit` flag records whether the `args:` key was absent; only that implicit default wraps interface prose into `argv.prose`, so an explicit declaration identical to the default shape never wraps. - `subst::Sources`: The six substitution inputs aggregate into one struct, and `substitute`, `substitute_inner`, and `resolve` take it in place of their parameter lists. - `derive_argv`: Parses the args string under the prompt's declaration: a default-declared prompt wraps the prose, a structured declaration parses JSON, and a failure or JSON null yields nil. - `crates/promptforge-api/src/execute/scheduler.rs`: The H1 freeze reads back whatever the pass left behind - the derived parse or its repair - and the walk inherits it frozen. An explicit-input call re-derives from its own args; a no-input call clones the context so the repair carries into the chain. - `substitute`: The `{{ argv }}` namespace renders the whole value and `{{ argv.key }}` indexes it; a nil argv is a hard error, never a silent empty string. - `the_executor_never_hard_errors_on_shape`: No shape enforcement anywhere: a declared string arriving as a number runs, because enforcement belongs to the prompt's H1. Design: new surface-growth @ crates/promptforge-lua/src/argv.rs boundary: pub Design: new flag-parameter @ crates/promptforge-api/src/execute/section_vm.rs::SectionVmSetup Design: new parameter-object @ crates/promptforge-api/src/subst.rs::Sources Design: new pure-function @ crates/promptforge-api/src/execute/context.rs::derive_argv deps: Prompt,str Design: new surface-growth @ crates/promptforge-api/src/subst.rs Design: new surface-growth @ crates/promptforge-parser/src/contract/args.rs::ArgsDecl boundary: pub Deferred: the tool channel sharing the argv spelling defers with the prompt-pack Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- crates/promptforge-api/src/execute.rs | 7 +- crates/promptforge-api/src/execute/context.rs | 44 ++- .../promptforge-api/src/execute/scheduler.rs | 24 +- .../src/execute/section_context.rs | 38 +- .../promptforge-api/src/execute/section_vm.rs | 12 +- .../src/execute/tests/args_surface.rs | 344 ++++++++++++++++++ .../promptforge-api/src/execute/tests/mod.rs | 1 + crates/promptforge-api/src/lua.rs | 2 +- crates/promptforge-api/src/lua/coro_tests.rs | 2 + crates/promptforge-api/src/subst.rs | 324 +++++++++++------ crates/promptforge-lua/src/argv.rs | 203 +++++++++++ crates/promptforge-lua/src/lib.rs | 3 + crates/promptforge-lua/src/tests.rs | 168 +++++++++ crates/promptforge-lua/src/vm.rs | 38 +- .../promptforge-parser/src/contract/args.rs | 22 +- .../promptforge-parser/src/contract/tests.rs | 36 ++ ...2026-09-13-1-capabilities-global-naming.md | 2 +- 17 files changed, 1131 insertions(+), 139 deletions(-) create mode 100644 crates/promptforge-api/src/execute/tests/args_surface.rs create mode 100644 crates/promptforge-lua/src/argv.rs diff --git a/crates/promptforge-api/src/execute.rs b/crates/promptforge-api/src/execute.rs index 0956e78a..12fb75a7 100644 --- a/crates/promptforge-api/src/execute.rs +++ b/crates/promptforge-api/src/execute.rs @@ -128,9 +128,10 @@ pub enum RunResult { /// Executes a parsed prompt and returns its final text. /// /// H1 is section 0: its Lua and prose blocks run once in source order with -/// the same surface every section gets (its only privilege, `argv` -/// writability, arrives with the args/argv step). If H1 does not return, -/// the H2 section walk runs and its final text is returned. +/// the same surface every section gets; its only privilege is `argv` +/// writability - every other section reads the value H1 left behind, frozen. +/// If H1 does not return, the H2 section walk runs and its final text is +/// returned. /// /// The free `run` receives an already-prepared [`RunContext`] and has /// nothing to prepare from: a context that never passed through diff --git a/crates/promptforge-api/src/execute/context.rs b/crates/promptforge-api/src/execute/context.rs index eeda4995..540d5aaa 100644 --- a/crates/promptforge-api/src/execute/context.rs +++ b/crates/promptforge-api/src/execute/context.rs @@ -116,6 +116,22 @@ fn bound_model_set(prompt: &Prompt, ctx: &RunContext) -> ModelSet { set } +/// Derives a section's `argv` from the args string under the prompt's +/// declaration: a default-declared prompt wraps the interface prose into +/// the default shape (`argv.prose`, with the empty string present, not +/// absent); a structured declaration parses the string as JSON, and a parse +/// failure or a JSON `null` reads as nil (`if argv then` is the malformed +/// check). The executor never hard-errors on shape. +fn derive_argv(prompt: &Prompt, args: &str) -> Option { + if prompt.frontmatter().args().is_default() { + return Some(serde_json::json!({ "prose": args })); + } + match serde_json::from_str(args) { + Ok(serde_json::Value::Null) | Err(_) => None, + Ok(value) => Some(value), + } +} + /// The ambient state one run shares across the execute subtree. /// /// Immutable for the run's lifetime and cheap to clone: every field is @@ -140,6 +156,11 @@ pub(crate) struct RunState { execution: Arc, /// The run's argument string for `{{ args }}` substitution. args: Arc, + /// The run's `argv`: the parsed form of `args` (`None` installs nil). + /// At construction this is the derived value the H1 pass starts from; + /// the walk's fork carries the value H1 left behind at the freeze, so + /// an H1 repair reaches every downstream section. + argv: Option>, /// The run's resource limits. limits: RunLimits, /// The run's observer handle. @@ -211,6 +232,7 @@ impl RunState { vfs: vfs.clone(), execution: Arc::from(ctx.name.as_str()), args: Arc::from(args), + argv: derive_argv(prompt, args).map(Arc::from), limits: ctx.limits, observer: Arc::clone(&ctx.observer), debug: ctx.debug.clone(), @@ -253,6 +275,13 @@ impl RunState { &self.args } + /// The run's `argv`: the parsed form of the args string, or `None` + /// (nil) when it did not parse. On the walk this is the value H1 left + /// behind at the freeze. + pub(crate) fn argv(&self) -> Option<&serde_json::Value> { + self.argv.as_deref() + } + /// The run's resource limits. pub(crate) fn limits(&self) -> RunLimits { self.limits @@ -345,24 +374,28 @@ impl RunState { self.on_delta.as_ref() } - /// The H1-to-walk handoff: the walk's start timestamp, set on a cheap - /// clone so the context H1 saw stays untouched. The tool and model sets + /// The H1-to-walk handoff: the walk's start timestamp and the `argv` + /// H1 left behind at the freeze, set on a cheap clone so the context + /// H1 saw stays untouched. The tool and model sets /// need no delta: they were built from the prepared bindings at /// construction, and H1's prompt-wide records (`tools.always`, /// `models.default`) landed in the same shared sets the views read. #[must_use] - pub(crate) fn with_walk_state(&self, when: &str) -> Self { + pub(crate) fn with_walk_state(&self, when: &str, argv: Option) -> Self { let mut ctx = self.clone(); ctx.when = Arc::from(when); + ctx.argv = argv.map(Arc::from); ctx } /// The context a contained chain runs under: `args` in place of the /// run's own, because a `call` call's explicit input overrides the - /// run's args for the chain. + /// run's args for the chain - and `argv` re-derives from the chain's + /// args, so the chain sees the parsed form of what it was passed. #[must_use] pub(crate) fn with_args(&self, args: &str) -> Self { let mut ctx = self.clone(); + ctx.argv = derive_argv(&self.prompt, args).map(Arc::from); ctx.args = Arc::from(args); ctx } @@ -400,6 +433,8 @@ impl RunState { ) -> SectionVmSetup<'a> { SectionVmSetup { args: &self.args, + argv: self.argv(), + argv_writable: false, sys, access, seed, @@ -438,6 +473,7 @@ impl fmt::Debug for RunState { .field("vfs", &"") .field("execution", &self.execution) .field("args", &self.args) + .field("argv", &self.argv) .field("limits", &self.limits) .field("observer", &"") .field("debug", &self.debug.as_ref().map(|_| "")) diff --git a/crates/promptforge-api/src/execute/scheduler.rs b/crates/promptforge-api/src/execute/scheduler.rs index b0107fff..028300e6 100644 --- a/crates/promptforge-api/src/execute/scheduler.rs +++ b/crates/promptforge-api/src/execute/scheduler.rs @@ -851,7 +851,8 @@ impl<'a> Scheduler<'a> { /// result is the shared generic completion. /// /// # Errors - /// Returns [`Error::Lua`] when the final `var` read-back fails, + /// Returns [`Error::Lua`] when the final `var` read-back fails or H1 + /// left `argv` as non-JSON data, /// [`Error::TimestampFormat`] when the walk's `when` fails to format, /// [`Error::Store`] when the backend refuses the walk's acquisition, /// or [`Error::Internal`] when the chain holds no frame. @@ -866,6 +867,9 @@ impl<'a> Scheduler<'a> { return Err(Error::internal("the H1 pass ends with a live frame")); }; let var = frame.read_var()?; + // The freeze: whatever `argv` H1 leaves behind - the derived parse + // or its repair - is what every walked section inherits, frozen. + let argv = frame.read_argv()?; drop(frame); // The pass's chain ends here: release its capability (and with it // the identity's claims) before the walk acquires its own. @@ -875,11 +879,11 @@ impl<'a> Scheduler<'a> { *root_result = Some(Ok(GENERIC_COMPLETION.to_owned())); return Ok(()); } - // The H1-to-walk handoff: the walk's context takes its live `when`; - // H1's prompt-wide records already landed in the shared sets the - // views read. + // The H1-to-walk handoff: the walk's context takes its live `when` + // and the frozen `argv`; H1's prompt-wide records already landed in + // the shared sets the views read. let when = now_rfc3339_checked()?; - let walk_ctx = self.ctx.with_walk_state(&when); + let walk_ctx = self.ctx.with_walk_state(&when, argv); let root = self.start_chain(walk_ctx, sections, start, None, &var, 0, None)?; self.install_root_slots(root)?; self.ready.push_back(root); @@ -2028,8 +2032,14 @@ impl<'a> Scheduler<'a> { "call recursion exceeded cap of {MAX_CALL_DEPTH}" ))); } - let args = input.unwrap_or_else(|| chain.ctx.args()).to_owned(); - let child_ctx = chain.ctx.with_args(&args); + // An explicit input forks the chain's args (and `argv` re-derives + // from them); a no-input call inherits the caller's context whole, + // so the run's frozen `argv` - H1's repair included - carries into + // the chain rather than re-deriving from the unchanged args. + let child_ctx = match input { + Some(input) => chain.ctx.with_args(input), + None => chain.ctx.clone(), + }; let client = chain.client.clone(); // A call chain is a blocking child: it borrows the caller's access // capability (the same serial thread of execution), so the caller's diff --git a/crates/promptforge-api/src/execute/section_context.rs b/crates/promptforge-api/src/execute/section_context.rs index 49217c2d..12bf0b6b 100644 --- a/crates/promptforge-api/src/execute/section_context.rs +++ b/crates/promptforge-api/src/execute/section_context.rs @@ -223,7 +223,11 @@ impl SectionContext { // excludes nothing and has no children. let visible = ctx.prompt().sections().to_vec(); let list_callback = move |heading: String| list_items_from_visible(&heading, &visible); - let setup = ctx.vm_setup(&sys, VmSeed::default(), access, title); + // H1's one privilege: `argv` installs writable, so the repair + // pattern can assign it; the executor reads the value back at the + // freeze (see the scheduler's H1-to-walk handoff). + let mut setup = ctx.vm_setup(&sys, VmSeed::default(), access, title); + setup.argv_writable = true; // Setup runs on the bare VM so a failure tears it down here: the // frame does not exist yet, so its `Drop` cannot own this path. if let Err(error) = setup_section_vm(&mut vm, &setup, list_callback) { @@ -366,6 +370,17 @@ impl SectionContext { Ok(self.var.clone()) } + /// Reads the H1 pass's `argv` back as JSON while the frame is live: the + /// value the walk's sections inherit frozen - the derived parse, or H1's + /// repair. `None` reads as nil. + /// + /// # Errors + /// Returns [`Error::Lua`](crate::Error::Lua) when H1 left `argv` as + /// non-JSON data, or [`Error::Internal`] if the VM is gone. + pub(crate) fn read_argv(&self) -> Result> { + self.vm()?.argv_json().map_err(Error::from) + } + /// Arms the completion flag: the block walk completed (a jump or /// return included) and the final `var` is read back, so the frame's /// drop fires `SECTION_FINISHED` after the teardown pair. No error @@ -397,20 +412,21 @@ impl SectionContext { /// [`Error::Internal`] if the VM is gone. pub(crate) fn install_lazy_prose(&self, ctx: &RunState, template: &str) -> Result<()> { let template = template.to_owned(); - let args = ctx.args().to_owned(); + let raw_args = ctx.args().to_owned(); + let argv = ctx.argv().cloned(); let item = self.item.clone(); self.vm()? .install_lazy_prose(move |state: ProseState| -> mlua::Result { let globals = |name: &str| (state.globals)(name).map_err(Error::from); - subst::substitute( - &template, - &args, - item.as_ref(), - &state.var, - &state.sys, - &globals, - ) - .map_err(mlua::Error::external) + let sources = subst::Sources { + args: &raw_args, + argv: argv.as_ref(), + item: item.as_ref(), + var: &state.var, + sys: &state.sys, + globals: &globals, + }; + subst::substitute(&template, &sources).map_err(mlua::Error::external) })?; Ok(()) } diff --git a/crates/promptforge-api/src/execute/section_vm.rs b/crates/promptforge-api/src/execute/section_vm.rs index 90dfc390..82f971e0 100644 --- a/crates/promptforge-api/src/execute/section_vm.rs +++ b/crates/promptforge-api/src/execute/section_vm.rs @@ -51,6 +51,11 @@ pub(crate) struct VmSeed<'a> { pub(crate) struct SectionVmSetup<'a> { /// The run's argument string, installed as the `args` global. pub(crate) args: &'a str, + /// The run's `argv` (the parsed form of `args`; `None` installs nil). + pub(crate) argv: Option<&'a serde_json::Value>, + /// H1 only: `argv` installs writable, so the repair pattern can assign + /// it; every other section gets the frozen value. + pub(crate) argv_writable: bool, /// The `sys` JSON the driver built for this section or arm. pub(crate) sys: &'a serde_json::Value, /// The chain step's VFS access capability backing the Lua `store` @@ -107,7 +112,12 @@ where if setup.ui.is_some() { vm.allow_raw_model_ids(); } - vm.inject_host_with_var(setup.args, setup.sys, setup.access, setup.seed.var)?; + let argv = if setup.argv_writable { + crate::lua::Argv::Writable(setup.argv) + } else { + crate::lua::Argv::Frozen(setup.argv) + }; + vm.inject_host_with_var(setup.args, setup.sys, setup.access, setup.seed.var, argv)?; vm.install_host_apis(setup.observer_arc, setup.section_name)?; if let Some(provider) = setup.ui { crate::lua::install_ui(vm.lua(), Arc::clone(provider))?; diff --git a/crates/promptforge-api/src/execute/tests/args_surface.rs b/crates/promptforge-api/src/execute/tests/args_surface.rs new file mode 100644 index 00000000..19054a42 --- /dev/null +++ b/crates/promptforge-api/src/execute/tests/args_surface.rs @@ -0,0 +1,344 @@ +//! The args/argv surface: `args` is the exact passed string always; `argv` +//! is the parsed JSON on success and nil otherwise (`if argv then` is the +//! malformed check); a default-declared prompt wraps interface prose into +//! `argv.prose`; `argv` is writable in H1 only and frozen when H1 completes, +//! so an H1 repair reaches every downstream section while an H2 assignment +//! is an error; and `{{ argv }}` joins the prose substitution namespaces. + +use super::*; + +/// The frontmatter every structured-args test shares: one declared +/// (required) `query` string. The declaration advertises and documents; it +/// never enforces - enforcement is the prompt's H1. +macro_rules! args_prompt { + ($body:literal) => { + concat!( + "---\nname: t\ndescription: d\npromptforge: 0\n", + "args:\n query:\n type: string\n", + "---\n\n", + $body + ) + }; +} + +/// Runs a structured-args fixture offline with the given args string. +async fn run_args(md: &str, args: &str) -> Result { + run(&fixture(md), args, &[], &TestStore::new(), silent()).await +} + +#[tokio::test] +async fn args_is_the_exact_passed_string_and_substitutes_unmodified() { + let md = args_prompt!( + "## Only\n\n\ +Args: {{ args }}\n\n\ +```lua\n\ +assert(args == ' spaced { not json ', 'args is the exact passed string')\n\ +return prose\n\ +```\n" + ); + let out = run_args(md, " spaced { not json ") + .await + .expect("the run succeeds"); + assert!( + out.contains("Args: spaced { not json "), + "{{ args }} renders the raw string unmodified: {out:?}" + ); +} + +#[tokio::test] +async fn argv_is_the_parsed_json_on_success() { + let md = args_prompt!( + "## Only\n\n\ +```lua\n\ +assert(argv, 'parsed JSON makes argv present')\n\ +assert(argv.query == 'papers', 'structured access is argv.query')\n\ +return argv.query\n\ +```\n" + ); + let out = run_args(md, "{\"query\":\"papers\"}") + .await + .expect("the run succeeds"); + assert_eq!(out, "papers"); +} + +#[tokio::test] +async fn argv_is_nil_on_malformed_json() { + // `if argv then` is the idiomatic malformed check. + let md = args_prompt!( + "## Only\n\n\ +```lua\n\ +if argv then error('malformed JSON must read as nil argv') end\n\ +return 'nil'\n\ +```\n" + ); + let out = run_args(md, "not json") + .await + .expect("malformed JSON is nil argv, not a run failure"); + assert_eq!(out, "nil"); +} + +#[tokio::test] +async fn valid_json_scalars_make_argv_a_number_or_boolean_and_null_reads_as_nil() { + let md = args_prompt!("## Only\n\n```lua\nreturn tostring(argv)\n```\n"); + assert_eq!( + run_args(md, "42").await.expect("a number argv"), + "42", + "a valid JSON number makes argv a number" + ); + assert_eq!( + run_args(md, "true").await.expect("a boolean argv"), + "true", + "a valid JSON boolean makes argv a boolean" + ); + let md = args_prompt!( + "## Only\n\n\ +```lua\n\ +if argv then error('JSON null must read as nil') end\n\ +return 'nil'\n\ +```\n" + ); + assert_eq!(run_args(md, "null").await.expect("null runs"), "nil"); +} + +#[tokio::test] +async fn the_executor_never_hard_errors_on_shape() { + // `query` is declared a string and arrives as a number: the run + // succeeds; shape enforcement belongs to the prompt's H1. + let md = args_prompt!("## Only\n\n```lua\nreturn tostring(argv.query)\n```\n"); + let out = run_args(md, "{\"query\":5}") + .await + .expect("a shape mismatch is the prompt's concern, not the executor's"); + assert_eq!(out, "5"); +} + +#[tokio::test] +async fn a_strict_h1_errors_on_a_missing_field() { + let md = args_prompt!( + "# T\n\n\ +```lua\n\ +assert(argv and argv.query, 'query is required')\n\ +```\n\n\ +## Only\n\n\ +```lua\nreturn 'unreachable'\n```\n" + ); + let error = run_args(md, "{}") + .await + .expect_err("the strict H1 path fails the run before the walk"); + assert!( + error.to_string().contains("query is required"), + "the assertion notice surfaces: {error}" + ); +} + +#[tokio::test] +async fn an_h1_repair_is_visible_to_every_downstream_section() { + let md = args_prompt!( + "# T\n\n\ +```lua\n\ +assert(argv == nil, 'the broken input starts as nil argv')\n\ +argv = { query = 'repaired' }\n\ +```\n\n\ +## First\n\n\ +Query: {{ argv.query }}\n\n\ +```lua\n\ +assert(argv.query == 'repaired', 'the repair reaches the first section')\n\ +var.from_first = prose\n\ +```\n\n\ +## Second\n\n\ +```lua\n\ +assert(argv.query == 'repaired', 'the repair reaches every downstream section')\n\ +assert(var.from_first == 'Query: repaired', 'the repair reaches substitution')\n\ +return argv.query\n\ +```\n" + ); + let out = run_args(md, "broken json") + .await + .expect("the H1 repair carries downstream"); + assert_eq!(out, "repaired"); +} + +#[tokio::test] +async fn assigning_argv_in_an_h2_section_is_an_error() { + let md = args_prompt!( + "## Only\n\n\ +```lua\n\ +argv = { query = 'hijacked' }\n\ +```\n" + ); + let error = run_args(md, "{\"query\":\"x\"}") + .await + .expect_err("an H2 argv assignment must fail"); + assert!( + error.to_string().contains("argv is frozen"), + "the error names the freeze: {error}" + ); +} + +#[tokio::test] +async fn an_h2_field_write_on_argv_is_an_error() { + let md = args_prompt!( + "## Only\n\n\ +```lua\n\ +argv.query = 'hijacked'\n\ +```\n" + ); + let error = run_args(md, "{\"query\":\"x\"}") + .await + .expect_err("an H2 field write on argv must fail"); + assert!( + error.to_string().contains("argv is frozen"), + "the error names the freeze: {error}" + ); +} + +#[tokio::test] +async fn absent_is_not_the_empty_string() { + // An optional field omitted is nil; passed empty it is "". The two are + // distinguishable in Lua. + let md = concat!( + "---\nname: t\ndescription: d\npromptforge: 0\n", + "args:\n prose:\n type: string\n optional: true\n", + "---\n\n", + "## Only\n\n\ +```lua\n\ +if argv.prose == nil then return 'absent' end\n\ +assert(argv.prose == '', 'present and empty is the empty string')\n\ +return 'empty'\n\ +```\n" + ); + assert_eq!( + run_args(md, "{}").await.expect("an omitted field runs"), + "absent" + ); + assert_eq!( + run_args(md, "{\"prose\":\"\"}") + .await + .expect("an empty field runs"), + "empty" + ); +} + +#[tokio::test] +async fn a_default_declared_prompt_wraps_interface_prose() { + // No `args:` key: the default declaration wraps prose into argv.prose, + // and args still holds the exact passed string. + let md = concat!( + "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n", + "## Only\n\n\ +```lua\n\ +assert(argv and argv.prose == 'hello there', 'prose wraps into argv.prose')\n\ +assert(args == 'hello there', 'args holds the exact passed string')\n\ +return argv.prose\n\ +```\n" + ); + let out = run_args(md, "hello there").await.expect("the wrap runs"); + assert_eq!(out, "hello there"); +} + +#[tokio::test] +async fn a_default_declared_prompt_wraps_empty_prose_as_present() { + // Interface prose is always present, so empty prose wraps as the + // present empty string, distinguishable from an absent field. + let md = concat!( + "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n", + "## Only\n\n\ +```lua\n\ +assert(argv, 'empty prose still wraps')\n\ +assert(argv.prose ~= nil, 'the field is present')\n\ +assert(argv.prose == '', 'present and empty is the empty string')\n\ +return 'ok'\n\ +```\n" + ); + let out = run_offline(md).await.expect("empty prose wraps"); + assert_eq!(out, "ok"); +} + +#[tokio::test] +async fn argv_joins_the_substitution_namespaces() { + let md = args_prompt!( + "## Only\n\n\ +Whole: {{ argv }}; Field: {{ argv.query }}\n\n\ +```lua\nreturn prose\n```\n" + ); + let out = run_args(md, "{\"query\":\"papers\",\"n\":2}") + .await + .expect("the substitution runs"); + assert_eq!(out, "Whole: {\"n\":2,\"query\":\"papers\"}; Field: papers"); +} + +#[tokio::test] +async fn dotted_indexing_into_a_scalar_argv_is_a_catchable_substitution_error() { + let md = args_prompt!( + "## Only\n\n\ +Value: {{ argv.query.x }}\n\n\ +```lua\n\ +local ok, err = pcall(function() return prose end)\n\ +assert(not ok, 'dotted indexing into a scalar must fail')\n\ +assert(tostring(err):match('missing'), 'a substitution error, never a silent empty string: ' .. tostring(err))\n\ +return 'caught'\n\ +```\n" + ); + let out = run_args(md, "{\"query\":\"scalar\"}") + .await + .expect("pcall catches the substitution error"); + assert_eq!(out, "caught"); +} + +#[tokio::test] +async fn substituting_a_nil_argv_is_an_error() { + let md = args_prompt!( + "## Only\n\n\ +Value: {{ argv }}\n\n\ +```lua\nreturn prose\n```\n" + ); + let error = run_args(md, "not json") + .await + .expect_err("a nil argv must not render as a silent empty string"); + assert!( + error.to_string().contains("argv"), + "the error names argv: {error}" + ); +} + +#[tokio::test] +async fn a_call_chain_derives_argv_from_its_own_args_frozen() { + let md = args_prompt!( + "## Main\n\n\ +```lua\n\ +return call('## Sub', '{\"query\":\"chain\"}')\n\ +```\n\n\ +## Sub\n\n\ +```lua\n\ +assert(args == '{\"query\":\"chain\"}', 'the chain sees its own args')\n\ +assert(argv.query == 'chain', 'argv derives from the chain args')\n\ +local wrote = pcall(function() argv.query = 'x' end)\n\ +assert(not wrote, 'the chain argv is frozen')\n\ +return argv.query\n\ +```\n" + ); + let out = run_args(md, "{\"query\":\"run\"}") + .await + .expect("the chain runs"); + assert_eq!(out, "chain"); +} + +#[tokio::test] +async fn a_no_input_call_inherits_the_repaired_argv() { + let md = args_prompt!( + "# T\n\n\ +```lua\nargv = { query = 'repaired' }\n```\n\n\ +## Main\n\n\ +```lua\n\ +return call('## Sub')\n\ +```\n\n\ +## Sub\n\n\ +```lua\n\ +assert(argv.query == 'repaired', 'a no-input call inherits the run argv')\n\ +return argv.query\n\ +```\n" + ); + let out = run_args(md, "broken") + .await + .expect("the no-input call inherits the repaired argv"); + assert_eq!(out, "repaired"); +} diff --git a/crates/promptforge-api/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs index 45ea9f9c..19958c7f 100644 --- a/crates/promptforge-api/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -1944,6 +1944,7 @@ impl RecordingCapture { } } +mod args_surface; mod debug_and_counts; mod exec_flow; mod exit_rules; diff --git a/crates/promptforge-api/src/lua.rs b/crates/promptforge-api/src/lua.rs index 7e8480fa..92effa19 100644 --- a/crates/promptforge-api/src/lua.rs +++ b/crates/promptforge-api/src/lua.rs @@ -12,7 +12,7 @@ //! here unchanged, so existing `promptforge_api::lua::*` paths keep working. pub(crate) use promptforge_lua::{ - CoroStep, LuaBlockResult, LuaFanoutResult, LuaProgram, MessageContent, MessageRecord, + Argv, CoroStep, LuaBlockResult, LuaFanoutResult, LuaProgram, MessageContent, MessageRecord, MessageRole, OverflowReason, ProseState, ScriptReport, SectionVm, ToolBinding, ToolCallCounts, ToolCallRecord, ToolOutputKind, ToolSet, ToolView, UserInputOutcome, append_message_record, current_tool_bindings, dispatch_tool, enrich_sys_model, install_section_loop_shim, diff --git a/crates/promptforge-api/src/lua/coro_tests.rs b/crates/promptforge-api/src/lua/coro_tests.rs index c974850f..671cd7b7 100644 --- a/crates/promptforge-api/src/lua/coro_tests.rs +++ b/crates/promptforge-api/src/lua/coro_tests.rs @@ -121,6 +121,8 @@ fn scheduler_vm_with_tools( ); let setup = SectionVmSetup { args: "", + argv: None, + argv_writable: false, sys: &sys, access: &access, seed: VmSeed { var, item: None }, diff --git a/crates/promptforge-api/src/subst.rs b/crates/promptforge-api/src/subst.rs index 67afae3f..52945f36 100644 --- a/crates/promptforge-api/src/subst.rs +++ b/crates/promptforge-api/src/subst.rs @@ -2,8 +2,9 @@ //! //! When a section's Lua first reads the lazy `prose` value, the harness //! resolves `{{ path }}` placeholders in the pending Markdown template. Lua -//! source is never substituted. Four sources are available: -//! `args` (the single raw input string), `item` (the current fanout arm's +//! source is never substituted. Five sources are available: +//! `args` (the single raw input string), `argv` (its parsed JSON form, nil +//! when the args did not parse), `item` (the current fanout arm's //! item value, nil outside arms), `var` (values the section's Lua wrote), //! and `sys` //! (runtime-provided metadata). An unknown first segment resolves as a bare @@ -54,6 +55,9 @@ pub(crate) enum SubstErrorKind { NullValue, /// `{{ item }}` was used outside a fanout arm. NilItem, + /// `{{ argv }}` was used when the args string did not parse (or H1 left + /// `argv` nil). + NilArgv, /// A table/array value failed to serialize to JSON, or a bare global was /// not JSON data. Serialize, @@ -141,40 +145,46 @@ pub(crate) fn render_item(item: &Value) -> String { serde_json::to_string(item).unwrap_or_default() } -/// Resolve every `{{ path }}` in `prose` against `args`, `item`, -/// `var`, `sys`, and the section's bare globals. +/// The value sources `{{ }}` placeholders resolve against. /// /// `var` and `sys` are JSON objects (`var` read back from the section's -/// Lua, `sys` built by the runtime). `item` is the current fanout +/// Lua, `sys` built by the runtime). `argv` is the parsed form of the args +/// string - the run's frozen value on the walk - or `None` when the args +/// did not parse; `{{ argv }}` renders the whole value and `{{ argv.key }}` +/// indexes it, with a nil `argv` a hard error. `item` is the current fanout /// arm's item value, or `None` outside arms; it renders per /// [`render_item`]. `globals` resolves a bare global by name: `Ok(None)` -/// when unset, `Ok(Some(_))` with its JSON form when set. This function -/// receives prose only and does not transform either compiled Lua phase. +/// when unset, `Ok(Some(_))` with its JSON form when set. +pub(crate) struct Sources<'a> { + /// The raw args string: `{{ args }}`. + pub(crate) args: &'a str, + /// The parsed form of the args string: `{{ argv }}` and dotted paths. + pub(crate) argv: Option<&'a Value>, + /// The current fanout arm's item value: `{{ item }}`. + pub(crate) item: Option<&'a Value>, + /// The section's `var` clipboard, read back from its Lua. + pub(crate) var: &'a Value, + /// The runtime-provided metadata: `{{ sys.key }}`. + pub(crate) sys: &'a Value, + /// The bare-global lookup for `{{ name }}` resolution. + pub(crate) globals: &'a dyn Fn(&str) -> Result>, +} + +/// Resolve every `{{ path }}` in `prose` against the [`Sources`]. +/// +/// This function receives prose only and does not transform either compiled +/// Lua phase. /// /// # Errors /// Returns [`Error::Substitution`](crate::Error::Substitution) for an unclosed /// `{{`, an unknown namespace or missing bare global, an empty or whitespace /// path segment, a missing key, a null value, a non-JSON bare global, -/// or `{{ item }}` when `item` is `None`. -pub(crate) fn substitute( - prose: &str, - args: &str, - item: Option<&Value>, - var: &Value, - sys: &Value, - globals: &dyn Fn(&str) -> Result>, -) -> Result { - Ok(substitute_inner(prose, args, item, var, sys, globals)?) +/// `{{ item }}` when `item` is `None`, or `{{ argv }}` when `argv` is `None`. +pub(crate) fn substitute(prose: &str, sources: &Sources<'_>) -> Result { + Ok(substitute_inner(prose, sources)?) } -fn substitute_inner( - prose: &str, - args: &str, - item: Option<&Value>, - var: &Value, - sys: &Value, - globals: &dyn Fn(&str) -> Result>, -) -> SubstResult { +fn substitute_inner(prose: &str, sources: &Sources<'_>) -> SubstResult { let mut out = String::with_capacity(prose.len()); let bytes = prose.as_bytes(); let mut i = 0; @@ -200,7 +210,7 @@ fn substitute_inner( ) })?; let path = after[..end].trim(); - out.push_str(&resolve(path, start, args, item, var, sys, globals)?); + out.push_str(&resolve(path, start, sources)?); i += 2 + end + 2; continue; } @@ -213,21 +223,61 @@ fn substitute_inner( Ok(out) } -/// Resolve a single `{{ }}` path to its rendered string. -fn resolve( +/// The nil-`argv` failure shared by the bare and dotted `{{ argv }}` paths. +fn nil_argv<'a>(argv: Option<&'a Value>, path: &str, offset: usize) -> SubstResult<&'a Value> { + argv.ok_or_else(|| { + SubstitutionError::new( + SubstErrorKind::NilArgv, + offset, + format!( + "{{{{ {} }}}} is nil (the args string is not JSON)", + path_preview(path) + ), + ) + }) +} + +/// Resolves an unknown first segment as a bare global: the host lookup +/// reads the section-local Lua global and converts it to JSON. +fn bare_global_root( + name: &str, path: &str, offset: usize, - args: &str, - item: Option<&Value>, - var: &Value, - sys: &Value, globals: &dyn Fn(&str) -> Result>, -) -> SubstResult { +) -> SubstResult { + globals(name) + .map_err(|error| { + SubstitutionError::with_source( + SubstErrorKind::Serialize, + offset, + format!( + "global '{}' in {{{{ {} }}}} is not JSON data", + path_preview(name), + path_preview(path) + ), + Box::new(error), + ) + })? + .ok_or_else(|| { + SubstitutionError::new( + SubstErrorKind::UnknownNamespace, + offset, + format!( + "unknown namespace or global '{}' in {{{{ {} }}}}", + path_preview(name), + path_preview(path) + ), + ) + }) +} + +/// Resolve a single `{{ }}` path to its rendered string. +fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult { if path == "args" { - return Ok(args.to_string()); + return Ok(sources.args.to_string()); } if path == "item" { - return item.map(render_item).ok_or_else(|| { + return sources.item.map(render_item).ok_or_else(|| { SubstitutionError::new( SubstErrorKind::NilItem, offset, @@ -235,6 +285,9 @@ fn resolve( ) }); } + if path == "argv" { + return render(nil_argv(sources.argv, path, offset)?, path, offset); + } // Validate the complete segment grammar before any lookup: every segment // (namespace included) must be nonempty and free of leading or trailing @@ -262,8 +315,9 @@ fn resolve( // reads the section-local Lua global and converts it to JSON. let global_value; let root = match namespace { - "var" => var, - "sys" => sys, + "var" => sources.var, + "sys" => sources.sys, + "argv" => nil_argv(sources.argv, path, offset)?, "args" | "item" => { return Err(SubstitutionError::new( SubstErrorKind::NotATable, @@ -272,30 +326,7 @@ fn resolve( )); } other => { - global_value = globals(other) - .map_err(|error| { - SubstitutionError::with_source( - SubstErrorKind::Serialize, - offset, - format!( - "global '{}' in {{{{ {} }}}} is not JSON data", - path_preview(other), - path_preview(path) - ), - Box::new(error), - ) - })? - .ok_or_else(|| { - SubstitutionError::new( - SubstErrorKind::UnknownNamespace, - offset, - format!( - "unknown namespace or global '{}' in {{{{ {} }}}}", - path_preview(other), - path_preview(path) - ), - ) - })?; + global_value = bare_global_root(other, path, offset, sources.globals)?; &global_value } }; @@ -391,24 +422,33 @@ mod tests { Ok(None) } + /// Sources with the stock args string and no argv/item, over the given + /// `var`/`sys`; tests override fields with struct-update syntax. + fn test_sources<'a>(var: &'a Value, sys: &'a Value) -> Sources<'a> { + Sources { + args: "Acme Corp", + argv: None, + item: None, + var, + sys, + globals: &no_globals, + } + } + fn run(prose: &str) -> Result { let var = json!({ "kind": "library", "count": 3, "row": { "a": 1 } }); let sys = json!({ "when": "2026-07-29T00:00:00Z", "id": 1 }); - substitute(prose, "Acme Corp", None, &var, &sys, &no_globals) + substitute(prose, &test_sources(&var, &sys)) } fn err_of(prose: &str) -> SubstitutionError { let var = json!({ "kind": "library", "row": { "a": 1 }, "arr": [1, 2] }); let sys = json!({ "id": 1 }); - substitute_inner( - prose, - "Acme Corp", - Some(&json!("i")), - &var, - &sys, - &no_globals, - ) - .expect_err("expected substitution failure") + let sources = Sources { + item: Some(&json!("i")), + ..test_sources(&var, &sys) + }; + substitute_inner(prose, &sources).expect_err("expected substitution failure") } #[test] @@ -435,6 +475,72 @@ mod tests { assert_eq!(run("hi {{ args }}!").unwrap(), "hi Acme Corp!"); } + // --- argv: the parsed-args namespace ------------------------------------ + + #[test] + fn resolves_argv_whole_value() { + let var = json!({}); + let sys = json!({}); + let argv = json!({ "query": "papers", "n": 2 }); + let sources = Sources { + argv: Some(&argv), + ..test_sources(&var, &sys) + }; + let out = substitute("got {{ argv }}", &sources).unwrap(); + assert_eq!(out, "got {\"n\":2,\"query\":\"papers\"}"); + } + + #[test] + fn resolves_argv_dotted_path() { + let var = json!({}); + let sys = json!({}); + let argv = json!({ "query": "papers", "row": { "a": 1 } }); + let sources = Sources { + argv: Some(&argv), + ..test_sources(&var, &sys) + }; + let out = substitute("q={{ argv.query }} cell={{ argv.row.a }}", &sources).unwrap(); + assert_eq!(out, "q=papers cell=1"); + } + + #[test] + fn scalar_argv_renders_whole() { + let var = json!({}); + let sys = json!({}); + let argv = json!(42); + let sources = Sources { + argv: Some(&argv), + ..test_sources(&var, &sys) + }; + let out = substitute("{{ argv }}", &sources).unwrap(); + assert_eq!(out, "42"); + } + + #[test] + fn nil_argv_is_an_error() { + let var = json!({}); + let sys = json!({}); + for prose in ["{{ argv }}", "{{ argv.x }}"] { + let e = substitute_inner(prose, &test_sources(&var, &sys)).unwrap_err(); + assert_eq!(e.kind, SubstErrorKind::NilArgv, "path {prose:?}"); + assert!(e.to_string().contains("argv"), "names argv: {e}"); + } + } + + #[test] + fn dotted_index_into_a_scalar_argv_is_an_error() { + // Never a silent empty string: the existing missing-key failure. + let var = json!({}); + let sys = json!({}); + let argv = json!("scalar"); + let sources = Sources { + argv: Some(&argv), + ..test_sources(&var, &sys) + }; + let e = substitute_inner("{{ argv.x }}", &sources).unwrap_err(); + assert_eq!(e.kind, SubstErrorKind::MissingKey); + } + #[test] fn resolves_var_scalar() { assert_eq!(run("a {{ var.kind }} paper").unwrap(), "a library paper"); @@ -503,15 +609,11 @@ mod tests { let sys = json!({}); // `var.payload` renders text that looks like a placeholder; it must be // emitted verbatim, never resolved against args. - let out = substitute( - "value: {{ var.payload }}", - "SECRET", - None, - &var, - &sys, - &no_globals, - ) - .unwrap(); + let sources = Sources { + args: "SECRET", + ..test_sources(&var, &sys) + }; + let out = substitute("value: {{ var.payload }}", &sources).unwrap(); assert_eq!(out, "value: {{ args }}"); } @@ -553,7 +655,11 @@ mod tests { let var = json!({}); let sys = json!({}); let globals = |name: &str| Ok((name == "answer").then(|| json!(42))); - let out = substitute("the answer is {{ answer }}", "", None, &var, &sys, &globals).unwrap(); + let sources = Sources { + globals: &globals, + ..test_sources(&var, &sys) + }; + let out = substitute("the answer is {{ answer }}", &sources).unwrap(); assert_eq!(out, "the answer is 42"); } @@ -562,7 +668,11 @@ mod tests { let var = json!({}); let sys = json!({}); let globals = |name: &str| Ok((name == "row").then(|| json!({ "a": { "b": 2 } }))); - let out = substitute("cell {{ row.a.b }}", "", None, &var, &sys, &globals).unwrap(); + let sources = Sources { + globals: &globals, + ..test_sources(&var, &sys) + }; + let out = substitute("cell {{ row.a.b }}", &sources).unwrap(); assert_eq!(out, "cell 2"); } @@ -571,7 +681,11 @@ mod tests { let var = json!({}); let sys = json!({}); let globals = |name: &str| Ok((name == "row").then(|| json!({ "a": 1 }))); - let out = substitute("{{ row }}", "", None, &var, &sys, &globals).unwrap(); + let sources = Sources { + globals: &globals, + ..test_sources(&var, &sys) + }; + let out = substitute("{{ row }}", &sources).unwrap(); assert_eq!(out, "{\"a\":1}"); } @@ -590,7 +704,11 @@ mod tests { assert_eq!(name, "f"); Err(crate::Error::Lua("global `f` is a function".to_owned())) }; - let e = substitute_inner("{{ f }}", "", None, &var, &sys, &globals).unwrap_err(); + let sources = Sources { + globals: &globals, + ..test_sources(&var, &sys) + }; + let e = substitute_inner("{{ f }}", &sources).unwrap_err(); assert_eq!(e.kind, SubstErrorKind::Serialize); assert!(e.to_string().contains("not JSON data")); assert!( @@ -621,10 +739,10 @@ mod tests { fn null_value_and_item_kinds() { let var = json!({ "n": Value::Null }); let sys = json!({}); - let e = substitute_inner("{{ var.n }}", "", None, &var, &sys, &no_globals).unwrap_err(); + let e = substitute_inner("{{ var.n }}", &test_sources(&var, &sys)).unwrap_err(); assert_eq!(e.kind, SubstErrorKind::NullValue); - let e = substitute_inner("{{ item }}", "", None, &var, &sys, &no_globals).unwrap_err(); + let e = substitute_inner("{{ item }}", &test_sources(&var, &sys)).unwrap_err(); assert_eq!(e.kind, SubstErrorKind::NilItem); } @@ -641,7 +759,7 @@ mod tests { fn array_renders_as_json() { let var = json!({ "arr": [1, 2, 3] }); let sys = json!({}); - let out = substitute("{{ var.arr }}", "", None, &var, &sys, &no_globals).unwrap(); + let out = substitute("{{ var.arr }}", &test_sources(&var, &sys)).unwrap(); assert_eq!(out, "[1,2,3]"); } @@ -649,15 +767,11 @@ mod tests { fn resolves_item_when_present() { let var = json!({}); let sys = json!({}); - let out = substitute( - "topic: {{ item }}", - "", - Some(&json!("the angle")), - &var, - &sys, - &no_globals, - ) - .unwrap(); + let sources = Sources { + item: Some(&json!("the angle")), + ..test_sources(&var, &sys) + }; + let out = substitute("topic: {{ item }}", &sources).unwrap(); assert_eq!(out, "topic: the angle"); } @@ -665,8 +779,8 @@ mod tests { fn item_nil_is_error() { let var = json!({}); let sys = json!({}); - let err = substitute("{{ item }}", "", None, &var, &sys, &no_globals) - .expect_err("nil item must fail"); + let err = + substitute("{{ item }}", &test_sources(&var, &sys)).expect_err("nil item must fail"); assert!( err.to_string().contains("nil"), "error must mention nil: {err}" @@ -677,15 +791,11 @@ mod tests { fn item_dot_path_is_error() { let var = json!({}); let sys = json!({}); - let err = substitute( - "{{ item.x }}", - "", - Some(&json!("text")), - &var, - &sys, - &no_globals, - ) - .expect_err("item is a string, not a table"); + let sources = Sources { + item: Some(&json!("text")), + ..test_sources(&var, &sys) + }; + let err = substitute("{{ item.x }}", &sources).expect_err("item is a string, not a table"); assert!( err.to_string().contains("not a table"), "error must say not a table: {err}" diff --git a/crates/promptforge-lua/src/argv.rs b/crates/promptforge-lua/src/argv.rs new file mode 100644 index 00000000..cea74627 --- /dev/null +++ b/crates/promptforge-lua/src/argv.rs @@ -0,0 +1,203 @@ +//! The `argv` global: the parsed form of the run's args string. +//! +//! `argv` installs at host injection in one of two modes. The H1 pass gets +//! a plain writable value, so the repair pattern lives there: read the +//! broken input from `args`, assign `argv = repaired`, and the executor +//! reads the value back when the pass completes. Every other section gets +//! the frozen value: reads work (absent fields read nil), and any +//! assignment - `argv = ...` or `argv.field = ...` at any depth - raises. +//! +//! The freeze rides on the `_G` metatable, the same composition the lazy +//! `prose` guard uses: `argv` is never a raw global in a frozen section, so +//! every read and every write of the name crosses the guard, and every +//! other key delegates to whatever metatable was installed first (the +//! `prose` guard installs later and shadows this pair as its delegates, so +//! the two compose). The table value itself is deep-frozen behind proxy +//! tables whose `__newindex` rejects every write. + +use super::{Error, Json, Lua, LuaSerdeExt, MultiValue, Result, Value}; + +/// How a section VM installs the `argv` global at host injection. `None` +/// installs nil either way, so `if argv then` is the idiomatic malformed +/// check. +#[derive(Debug, Clone, Copy)] +pub enum Argv<'a> { + /// The H1 pass: a plain writable value, so the repair pattern can + /// assign `argv`; the executor reads the value back at the freeze. + Writable(Option<&'a Json>), + /// Every other section: reads work (absent fields read nil), and every + /// assignment - `argv = ...` or a field write at any depth - raises. + Frozen(Option<&'a Json>), +} + +/// Marker field on a metatable this module installed: a re-install reuses +/// the recorded delegates instead of chaining a new handler over its own. +const GUARD_MARKER: &str = "__promptforge_argv_guard"; +/// The metatable field recording the `__index` the guard shadows. +const DELEGATE_INDEX: &str = "__promptforge_argv_delegate_index"; +/// The metatable field recording the `__newindex` the guard shadows. +const DELEGATE_NEWINDEX: &str = "__promptforge_argv_delegate_newindex"; + +/// Installs `argv` as a plain writable global: the H1 pass's mode. `None` +/// (malformed args, or JSON null) installs nil, so `if argv then` is the +/// idiomatic malformed check. +/// +/// # Errors +/// Returns [`Error::Lua`] if the value cannot be bridged or installed. +pub(crate) fn install_writable(lua: &Lua, argv: Option<&Json>) -> Result<()> { + let value = match argv { + None | Some(Json::Null) => Value::Nil, + Some(json) => lua.to_value(json).map_err(Error::lua)?, + }; + lua.globals().raw_set("argv", value).map_err(Error::lua) +} + +/// Installs `argv` frozen: reads work, every assignment raises. This is the +/// mode of every section but H1 - the value H1 left behind at the freeze. +/// +/// # Errors +/// Returns [`Error::Lua`] if the value cannot be bridged or the guard +/// metatable cannot be built or installed. +pub(crate) fn install_frozen(lua: &Lua, argv: Option<&Json>) -> Result<()> { + let frozen = frozen_json_value(lua, argv)?; + let globals = lua.globals(); + let old = globals.metatable(); + // The delegates the new guard shadows: a metatable of our own already + // recorded its delegates, so a re-install reuses them rather than + // chaining over the previous handler; any other metatable (the prose + // guard's, a shared library's) contributes its own index pair. + let (delegate_index, delegate_newindex) = match &old { + Some(old) if matches!(old.raw_get::(GUARD_MARKER), Ok(Value::Boolean(true))) => ( + old.raw_get::(DELEGATE_INDEX).map_err(Error::lua)?, + old.raw_get::(DELEGATE_NEWINDEX) + .map_err(Error::lua)?, + ), + Some(old) => ( + old.raw_get::("__index").map_err(Error::lua)?, + old.raw_get::("__newindex").map_err(Error::lua)?, + ), + None => (Value::Nil, Value::Nil), + }; + let metatable = lua.create_table().map_err(Error::lua)?; + // Carry every other field the previous metatable installed, then shadow + // the index pair with the argv guard. + if let Some(old) = &old { + for pair in old.clone().pairs::() { + let (key, value) = pair.map_err(Error::lua)?; + let shadowed = + matches!(&key, Value::String(name) if name == "__index" || name == "__newindex"); + if !shadowed { + metatable.raw_set(key, value).map_err(Error::lua)?; + } + } + } + metatable.raw_set(GUARD_MARKER, true).map_err(Error::lua)?; + metatable + .raw_set(DELEGATE_INDEX, delegate_index.clone()) + .map_err(Error::lua)?; + metatable + .raw_set(DELEGATE_NEWINDEX, delegate_newindex.clone()) + .map_err(Error::lua)?; + + let index = lua + .create_function(move |_, (target, key): (mlua::Table, Value)| { + if matches!(&key, Value::String(name) if name == "argv") { + return Ok(frozen.clone()); + } + match &delegate_index { + Value::Function(function) => Ok(function + .call::((target, key))? + .into_iter() + .next() + .unwrap_or(Value::Nil)), + Value::Table(table) => table.get(key), + _ => Ok(Value::Nil), + } + }) + .map_err(Error::lua)?; + metatable.raw_set("__index", index).map_err(Error::lua)?; + + let newindex = lua + .create_function( + move |_, (target, key, value): (mlua::Table, Value, Value)| -> mlua::Result<()> { + if matches!(&key, Value::String(name) if name == "argv") { + return Err(mlua::Error::runtime( + "argv is frozen outside H1: assign it in H1 only", + )); + } + match &delegate_newindex { + Value::Function(function) => { + function.call::((target, key, value))?; + Ok(()) + } + Value::Table(table) => table.set(key, value), + _ => target.raw_set(key, value), + } + }, + ) + .map_err(Error::lua)?; + metatable + .raw_set("__newindex", newindex) + .map_err(Error::lua)?; + globals.set_metatable(Some(metatable)).map_err(Error::lua) +} + +/// Builds the frozen Lua form of an argv JSON value: tables become +/// deep-frozen proxies, scalars bridge directly, and absent or null reads +/// as nil. +fn frozen_json_value(lua: &Lua, value: Option<&Json>) -> Result { + match value { + None | Some(Json::Null) => Ok(Value::Nil), + Some(Json::Array(values)) => { + let data = lua + .create_table_with_capacity(values.len(), 0) + .map_err(Error::lua)?; + for (index, value) in values.iter().enumerate() { + data.raw_set(index + 1, frozen_json_value(lua, Some(value))?) + .map_err(Error::lua)?; + } + freeze_table(lua, data).map(Value::Table) + } + Some(Json::Object(values)) => { + let data = lua + .create_table_with_capacity(0, values.len()) + .map_err(Error::lua)?; + for (key, value) in values { + data.raw_set(key.as_str(), frozen_json_value(lua, Some(value))?) + .map_err(Error::lua)?; + } + freeze_table(lua, data).map(Value::Table) + } + Some(scalar) => lua.to_value(scalar).map_err(Error::lua), + } +} + +/// Wraps a plain data table in a frozen proxy: reads pass through `__index` +/// to the data (whose nested tables are already frozen proxies, and whose +/// absent keys read nil), and every write raises the freeze error. +fn freeze_table(lua: &Lua, data: mlua::Table) -> Result { + let proxy = lua.create_table().map_err(Error::lua)?; + let metatable = lua.create_table().map_err(Error::lua)?; + metatable.raw_set("__index", data).map_err(Error::lua)?; + let newindex = lua + .create_function( + |_, (_proxy, key, _value): (Value, Value, Value)| -> mlua::Result<()> { + let field = match &key { + Value::String(name) => format!("'{}'", name.to_string_lossy()), + other => format!("{other:?}"), + }; + Err(mlua::Error::runtime(format!( + "argv is frozen outside H1: cannot set field {field}" + ))) + }, + ) + .map_err(Error::lua)?; + metatable + .raw_set("__newindex", newindex) + .map_err(Error::lua)?; + metatable + .raw_set("__metatable", "argv is frozen") + .map_err(Error::lua)?; + proxy.set_metatable(Some(metatable)).map_err(Error::lua)?; + Ok(proxy) +} diff --git a/crates/promptforge-lua/src/lib.rs b/crates/promptforge-lua/src/lib.rs index 781cb7a2..15bfa05e 100644 --- a/crates/promptforge-lua/src/lib.rs +++ b/crates/promptforge-lua/src/lib.rs @@ -83,6 +83,7 @@ pub(crate) fn log_byte_budget(log_events: u32) -> usize { } mod alias; +mod argv; mod collection; mod compactors; mod error; @@ -116,6 +117,8 @@ mod runtime_events; // here. These are `#[doc(hidden)]` cross-crate seams, not host API; // `LuaProgram` is the documented exception. #[doc(hidden)] +pub use crate::argv::Argv; +#[doc(hidden)] pub use compactors::{Compactor, OverflowReason, invoke_selected, is_context_overflow, precheck}; #[doc(hidden)] pub use coro::{ diff --git a/crates/promptforge-lua/src/tests.rs b/crates/promptforge-lua/src/tests.rs index bb716a41..db1cf18d 100644 --- a/crates/promptforge-lua/src/tests.rs +++ b/crates/promptforge-lua/src/tests.rs @@ -3082,3 +3082,171 @@ fn untrusted_global_rejects_a_non_string_argument() { "a non-string argument must surface as a Lua error, got {error:?}" ); } + +// --- args/argv surface: the argv global and its H1-only writability ------- + +/// Builds a section VM with `argv` installed the way the executor installs +/// it: writable for the H1 pass, frozen for every other section. +fn argv_vm(argv: Option<&Json>, writable: bool) -> SectionVm { + let mut vm = SectionVm::new_for_section( + &test_nonce(), + &shared_set(ToolSet::default()), + &Arc::new(Mutex::new(ModelSet::default())), + EXECUTION, + &NullObserver::default(), + "Argv", + ) + .expect("section VM must build"); + let argv = if writable { + Argv::Writable(argv) + } else { + Argv::Frozen(argv) + }; + vm.inject_host_with_var("", &json!({}), &fresh_access(), None, argv) + .expect("host must inject"); + vm +} + +/// Runs one chunk on an argv VM, returning the block's failure. +fn run_argv(vm: &SectionVm, source: &str) -> Result> { + run_scalar(vm, &program(source), &NullObserver::default(), "Argv") +} + +#[test] +fn frozen_argv_reads_through_the_guard() { + let argv = json!({ "query": "papers", "nested": { "hits": 2 } }); + let vm = argv_vm(Some(&argv), false); + let out = run_argv( + &vm, + "assert(argv.query == 'papers', 'a field reads through')\n\ + assert(argv.nested.hits == 2, 'a nested field reads through')\n\ + assert(argv.absent == nil, 'an absent field reads nil')\n\ + return argv.query", + ) + .expect("frozen argv must read"); + assert_eq!(out.as_deref(), Some("papers")); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn frozen_argv_rejects_reassignment() { + let argv = json!({ "query": "papers" }); + let vm = argv_vm(Some(&argv), false); + let error = run_argv(&vm, "argv = { query = 'hijacked' }") + .expect_err("reassigning argv outside H1 must fail"); + assert!( + error.to_string().contains("argv is frozen"), + "the error names the freeze: {error}" + ); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn frozen_argv_rejects_writes_at_any_depth() { + let argv = json!({ "query": "papers", "nested": { "hits": 2 } }); + let vm = argv_vm(Some(&argv), false); + let error = run_argv(&vm, "argv.query = 'hijacked'") + .expect_err("a field write on frozen argv must fail"); + assert!( + error.to_string().contains("argv is frozen"), + "the error names the freeze: {error}" + ); + let error = run_argv(&vm, "argv.nested.hits = 3") + .expect_err("a nested field write on frozen argv must fail"); + assert!( + error.to_string().contains("argv is frozen"), + "the deep freeze rejects nested writes: {error}" + ); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn frozen_nil_argv_reads_nil_and_rejects_assignment() { + let vm = argv_vm(None, false); + let out = run_argv(&vm, "assert(argv == nil, 'no argv reads nil') return 'ok'") + .expect("a nil argv reads nil"); + assert_eq!(out.as_deref(), Some("ok")); + let error = + run_argv(&vm, "argv = {}").expect_err("assigning a nil argv outside H1 must still fail"); + assert!( + error.to_string().contains("argv is frozen"), + "the error names the freeze: {error}" + ); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn frozen_scalar_argv_reads_and_rejects_assignment() { + let argv = json!(5); + let vm = argv_vm(Some(&argv), false); + let out = + run_argv(&vm, "assert(argv == 5) return tostring(argv)").expect("a scalar argv reads"); + assert_eq!(out.as_deref(), Some("5")); + let error = run_argv(&vm, "argv = 6").expect_err("reassigning a scalar argv must fail"); + assert!( + error.to_string().contains("argv is frozen"), + "the error names the freeze: {error}" + ); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn the_frozen_argv_guard_leaves_other_globals_alone() { + let argv = json!({ "query": "papers" }); + let vm = argv_vm(Some(&argv), false); + let out = run_argv( + &vm, + "scratch = 42\n\ + assert(scratch == 42, 'a bare global still assigns')\n\ + assert(absent_global == nil, 'an absent global still reads nil')\n\ + return 'ok'", + ) + .expect("ordinary globals must be untouched by the argv guard"); + assert_eq!(out.as_deref(), Some("ok")); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn writable_argv_repairs_and_reads_back() { + // The H1 repair pattern: malformed args start as nil argv; H1 assigns + // the repaired table; the host reads the repair back at the freeze. + let vm = argv_vm(None, true); + let out = run_argv( + &vm, + "assert(argv == nil, 'malformed args start as nil argv')\n\ + argv = { query = 'repaired' }\n\ + argv.extra = 1\n\ + return argv.query", + ) + .expect("H1 argv is writable"); + assert_eq!(out.as_deref(), Some("repaired")); + let read_back = vm.argv_json().expect("the repair reads back"); + assert_eq!(read_back, Some(json!({ "query": "repaired", "extra": 1 }))); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn writable_argv_field_writes_read_back() { + let argv = json!({ "query": "broken" }); + let vm = argv_vm(Some(&argv), true); + let out = run_argv(&vm, "argv.query = 'fixed' return argv.query") + .expect("a field write on the writable argv runs"); + assert_eq!(out.as_deref(), Some("fixed")); + let read_back = vm.argv_json().expect("the field write reads back"); + assert_eq!(read_back, Some(json!({ "query": "fixed" }))); + vm.teardown(&NullObserver::default(), "Argv"); +} + +#[test] +fn argv_read_back_rejects_a_non_data_assignment() { + let vm = argv_vm(None, true); + run_argv(&vm, "argv = function() end return 'ok'").expect("the assignment itself runs"); + let error = vm + .argv_json() + .expect_err("a function argv cannot read back as JSON"); + assert!( + error.to_string().contains("argv must be JSON data"), + "the error says why: {error}" + ); + vm.teardown(&NullObserver::default(), "Argv"); +} diff --git a/crates/promptforge-lua/src/vm.rs b/crates/promptforge-lua/src/vm.rs index bbfa94e2..863f8070 100644 --- a/crates/promptforge-lua/src/vm.rs +++ b/crates/promptforge-lua/src/vm.rs @@ -1,5 +1,5 @@ use super::{ - Access, Arc, AtomicU32, AtomicUsize, BTreeMap, DEFAULT_LUA_LOG_EVENTS, + Access, Arc, Argv, AtomicU32, AtomicUsize, BTreeMap, DEFAULT_LUA_LOG_EVENTS, DEFAULT_LUA_MEMORY_BYTES, Error, Function, GuardNonce, InstructionBudget, IntoLuaMulti, Json, Lua, LuaBlockResult, LuaModelHandle, LuaOptions, LuaProgram, LuaSerdeExt, LuaToolHandle, ModelBinding, ModelRuntime, ModelSet, ModelView, ModelsInferHook, MultiValue, Mutex, Observer, @@ -443,7 +443,7 @@ impl SectionVm { /// # Ok::<(), promptforge_lua::Error>(()) /// ``` pub fn inject_host(&mut self, args: &str, sys: &Json, access: &Arc) -> Result<()> { - self.inject_host_with_var(args, sys, access, None) + self.inject_host_with_var(args, sys, access, None, Argv::Frozen(None)) } /// Installs host values while seeding `var` from an earlier VM. @@ -456,15 +456,25 @@ impl SectionVm { /// spawned identity and a conflicting second live identity surfaces as /// a write race. /// + /// `argv` is the parsed form of the args string, installed per its + /// [`Argv`] mode: writable for the H1 pass (whose repaired value the + /// executor reads back with [`argv_json`](Self::argv_json)), frozen for + /// every other section. + /// /// # Errors /// Returns [`Error::Lua`] if host values cannot be bridged or were already /// injected. + #[expect( + clippy::similar_names, + reason = "args and argv are the spec'd global names; the pair is intentional" + )] pub fn inject_host_with_var( &mut self, args: &str, sys: &Json, access: &Arc, initial_var: Option<&Json>, + argv: Argv<'_>, ) -> Result<()> { if self.host_injected { return Err(Error::Lua( @@ -474,6 +484,10 @@ impl SectionVm { let globals = self.lua.globals(); globals.raw_set("args", args).map_err(Error::lua)?; + match argv { + Argv::Writable(value) => crate::argv::install_writable(&self.lua, value)?, + Argv::Frozen(value) => crate::argv::install_frozen(&self.lua, value)?, + } let sys_table = seal_sys(&self.lua, sys)?; globals.raw_set("sys", sys_table).map_err(Error::lua)?; { @@ -822,6 +836,26 @@ impl SectionVm { } } + /// Reads the `argv` global back as JSON at the H1 freeze: `None` when + /// nil, its JSON form otherwise. Call this on the H1 VM only - a frozen + /// section's `argv` sits behind the guard proxy, which is not the + /// read-back path. + /// + /// # Errors + /// Returns [`Error::Lua`] when H1 left `argv` as a function, userdata, + /// or thread, or when its value cannot be represented as JSON. + pub fn argv_json(&self) -> Result> { + let value: Value = self.lua.globals().get("argv").map_err(Error::lua)?; + match value { + Value::Nil => Ok(None), + Value::Function(_) | Value::UserData(_) | Value::Thread(_) => Err(Error::Lua(format!( + "argv must be JSON data, got {}", + value.type_name() + ))), + other => Ok(Some(self.lua.from_value(other).map_err(Error::lua)?)), + } + } + /// Sets a global in the VM to the Lua form of a JSON value, overwriting /// any existing value. /// diff --git a/crates/promptforge-parser/src/contract/args.rs b/crates/promptforge-parser/src/contract/args.rs index db0791a9..56fe5bb3 100644 --- a/crates/promptforge-parser/src/contract/args.rs +++ b/crates/promptforge-parser/src/contract/args.rs @@ -135,9 +135,21 @@ impl<'de> Deserialize<'de> for ArgDecl { #[non_exhaustive] pub struct ArgsDecl { fields: BTreeMap, + /// True only when the declaration is the implicit default (the `args:` + /// key was absent): interface prose wraps into `argv.prose`. An + /// explicit `args:` key - even one identical to the default shape - is + /// a structured declaration and never wraps. + implicit: bool, } impl ArgsDecl { + /// Returns whether this declaration is the implicit default (no `args:` + /// key), whose interface prose wraps into `argv.prose`. + #[must_use] + pub fn is_default(&self) -> bool { + self.implicit + } + /// Returns the declaration of the arg named `name`, when present. #[must_use] pub fn get(&self, name: &str) -> Option<&ArgDecl> { @@ -175,7 +187,10 @@ impl Default for ArgsDecl { description: Some("Freeform input for this prompt".to_owned()), }, ); - ArgsDecl { fields } + ArgsDecl { + fields, + implicit: true, + } } } @@ -185,6 +200,9 @@ impl<'de> Deserialize<'de> for ArgsDecl { D: serde::Deserializer<'de>, { let fields = deserialize_contract_map(deserializer, "arg name", None)?; - Ok(ArgsDecl { fields }) + Ok(ArgsDecl { + fields, + implicit: false, + }) } } diff --git a/crates/promptforge-parser/src/contract/tests.rs b/crates/promptforge-parser/src/contract/tests.rs index a4a4d994..cdd8f904 100644 --- a/crates/promptforge-parser/src/contract/tests.rs +++ b/crates/promptforge-parser/src/contract/tests.rs @@ -306,6 +306,42 @@ fn an_arg_name_must_match_the_alias_grammar() { assert_eq!(error.kind(), ParseErrorKind::Frontmatter); } +#[test] +fn an_absent_args_key_marks_the_default_declaration() { + // No `args:` key: the implicit default declaration, whose interface + // prose wraps into `argv.prose`. + let prompt = parse("name: x\ndescription: d\n").expect("no args key parses"); + assert!( + prompt.frontmatter().args().is_default(), + "an absent args key yields the default declaration" + ); + + // An explicit `args:` key is a structured declaration, which never + // wraps. + let prompt = parse("name: x\ndescription: d\nargs:\n query:\n type: string\n") + .expect("explicit args parse"); + assert!(!prompt.frontmatter().args().is_default()); + + // Even an explicit declaration identical to the default shape is + // structured: declared is not defaulted. + let prompt = parse(concat!( + "name: x\ndescription: d\n", + "args:\n", + " prose:\n", + " type: string\n", + " optional: true\n", + " description: Freeform input for this prompt\n", + )) + .expect("a default-shaped explicit declaration parses"); + let args = prompt.frontmatter().args(); + assert!(!args.is_default()); + assert_ne!( + *args, + super::ArgsDecl::default(), + "an explicit declaration never equals the implicit default" + ); +} + #[test] fn an_unknown_arg_type_is_rejected() { let yaml = "name: x\ndescription: d\nargs:\n flag:\n type: text\n"; diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 99341d1d..c35a1b9b 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -996,7 +996,7 @@ In `promptforge-lua`: `tools.bind` removed entirely (binding is frontmatter), `m -### Step 14: args/argv surface and substitution +### Step 14: args/argv surface and substitution [completed] - Component: binding From 50078097a43beb297b584187b949a647c405ca15 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 00:42:27 -0700 Subject: [PATCH 15/30] Migrate prose bindings to frontmatter declarations Moves the shipped prompts, the executor fixtures, the fenced guide examples, and the remaining executor test fixtures off prose binding calls and onto frontmatter declarations, where model roles carry keywords, context floors, and descriptions, tool slots name an exact path or a want description, and the default call designates a declared role by its label. The test helper that rewrote the canonical prose default into the label form loses that rewrite, because every fixture now carries its final shape literally; fixtures with binding calls of their own keep them and gain only the role declaration. The guide chapters now teach the frontmatter forms in place of the prose calls. - `crates/promptforge-api/src/execute/tests/mod.rs` - `ensure_model_h1` no longer rewrites the canonical prose default into the label form; every fixture carries its final shape literally, and prompts with their own `models.default` call or the legacy `models.bind` of the removal tests keep their shape and gain only the writer role declaration. - `prompts/research-person.md` - declares the `promptforge/web` capability and fills the search and fetch slots by exact global path instead of prose capability descriptions. - `models.default` - every touched prompt, fixture, and guide example drops the multi-argument bind-and-designate form for the single-label form over a frontmatter-declared role. - `guide/promptforge-language-guide.md` - carries the same Binding a model, default model, and Declaring a tool rewrites as the chapter sources, teaching frontmatter roles and tool slots. - `prompts/analyst-example.md` - the old prose bind's frozen temperature and thinking options have no frontmatter counterpart and are dropped; the analyst role keeps only its context floor and description. - `guide/src/language/07-tools.md` - the paragraph after the rewritten example still describes `tools.bind` advertising and resolution behavior, and the same paragraph survives in `guide/promptforge-language-guide.md`. Design: removes shim @ crates/promptforge-api/src/execute/tests/mod.rs::ensure_model_h1 Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- README.md | 4 ++- .../src/execute/tests/debug_and_counts.rs | 12 ++++----- .../promptforge-api/src/execute/tests/mod.rs | 16 ++++-------- .../src/execute/tests/model_and_reply.rs | 4 +-- .../tests/prompts/execution/real-text.md | 5 +++- .../tests/prompts/execution/real-tool-call.md | 9 +++++-- guide/promptforge-language-guide.md | 25 +++++++++++-------- guide/src/language/06-models.md | 17 +++++++------ guide/src/language/07-tools.md | 8 +++--- prompts/analyst-example.md | 12 ++++----- prompts/greet.md | 4 ++- prompts/hello.md | 4 ++- prompts/research-person.md | 11 +++++--- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 14 files changed, 78 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 2a3232d5..230918b0 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,14 @@ The prompt language is the programming surface. A prompt is a markdown document: name: greet description: Greet the named input using a Lua-computed value promptforge: 0 +models: + writer: {} --- # Greet ```lua -models.default("writer", "A model suited for careful analysis, coding, and general assistance") +models.default("writer") ``` ## Main diff --git a/crates/promptforge-api/src/execute/tests/debug_and_counts.rs b/crates/promptforge-api/src/execute/tests/debug_and_counts.rs index 1efb4e4a..8de2a9e1 100644 --- a/crates/promptforge-api/src/execute/tests/debug_and_counts.rs +++ b/crates/promptforge-api/src/execute/tests/debug_and_counts.rs @@ -85,9 +85,9 @@ async fn nested_model_infer_capture_reaches_the_debug_sink() { let gateway = ScriptedGateway::start(vec![resp_text("final answer")]).await; let addr = gateway.addr(); let capture = Arc::new(RecordingCapture::default()); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ - writer = models.default('writer', 'A general model for tests')\n```\n\n\ + writer = models.default('writer')\n```\n\n\ ## Only\n\n\ ```lua\n\ local text = models.infer(writer, 'say hello')\n\ @@ -132,9 +132,9 @@ async fn fanout_arm_debug_events_reach_the_run_sink() { let gateway = ScriptedGateway::start(vec![resp_text("arm reply")]).await; let addr = gateway.addr(); let capture = Arc::new(RecordingCapture::default()); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ - models.default('writer', 'A general model for tests')\n```\n\n\ + models.default('writer')\n```\n\n\ ## Parent\n\n\ ```lua\n\ local r = fanout('### Worker', {'alpha'})\n\ @@ -522,9 +522,9 @@ async fn handle_infer_returns_text_without_touching_reply_or_sys() { // sets `reply` or `sys.reply_finish_reason`. let gateway = ScriptedGateway::start(vec![resp_text("pong")]).await; let addr = gateway.addr(); - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n```lua shared\n\ - writer = models.default('writer', 'A general model for tests')\n```\n\n\ + writer = models.default('writer')\n```\n\n\ ## Only\n\n\ ```lua\n\ local text = models.infer(writer, 'say hello')\n\ diff --git a/crates/promptforge-api/src/execute/tests/mod.rs b/crates/promptforge-api/src/execute/tests/mod.rs index 19958c7f..0177009e 100644 --- a/crates/promptforge-api/src/execute/tests/mod.rs +++ b/crates/promptforge-api/src/execute/tests/mod.rs @@ -135,19 +135,13 @@ fn test_completion_options() -> CompletionOptions { } /// Declares the `writer` role and parks it as the prompt-wide default, so a -/// model-facing fixture prompt runs its sections under a bound model. The -/// canonical prose default rewrites to the label form over the declared -/// role; prompts with their own richer `models.bind`/`models.default` -/// shapes keep them (hand-migration cases). +/// model-facing fixture prompt runs its sections under a bound model. +/// Prompts carrying their own `models.default` call (or the legacy +/// `models.bind` of the removal tests) keep their shape and get only the +/// role declaration. fn ensure_model_h1(md: &str) -> String { - let mut source = md.to_string(); + let source = md.to_string(); if source.contains("models.default") || source.contains("models.bind") { - // The canonical prose default becomes the label form over a - // declared role; richer bind shapes are hand-migration cases. - source = source.replace( - "models.default('writer', 'A general model for tests')", - "models.default('writer')", - ); return declare_writer(&source); } let source = declare_writer(&source); diff --git a/crates/promptforge-api/src/execute/tests/model_and_reply.rs b/crates/promptforge-api/src/execute/tests/model_and_reply.rs index 52d73939..2eee9316 100644 --- a/crates/promptforge-api/src/execute/tests/model_and_reply.rs +++ b/crates/promptforge-api/src/execute/tests/model_and_reply.rs @@ -388,9 +388,9 @@ async fn model_required_when_infer_has_no_binding() { #[tokio::test] async fn shared_function_sees_sys_model_unknown_before_scope_close() { - let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ + let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n writer: {}\n---\n\n\ # Test prompt\n\n\ -```lua\nmodels.default('writer', 'A general model for tests')\n```\n\n\ +```lua\nmodels.default('writer')\n```\n\n\ ```lua shared\nfunction read_sys_model()\n return sys.model\nend\n```\n\n\ ## Only\n\n```lua\nreturn read_sys_model()\n```\n\nprose\n"; let error = run(&bound_for_model(md), "", &[], &TestStore::new(), silent()) diff --git a/crates/promptforge-api/tests/prompts/execution/real-text.md b/crates/promptforge-api/tests/prompts/execution/real-text.md index 6b728068..6c112f23 100644 --- a/crates/promptforge-api/tests/prompts/execution/real-text.md +++ b/crates/promptforge-api/tests/prompts/execution/real-text.md @@ -3,12 +3,15 @@ name: real_text description: Exercise one deterministic real-model text completion and epilog promptforge: 0 max_tool_iterations: 1 +models: + writer: + description: A careful analysis model suited to structured reasoning and long-context review --- # Real Text ```lua -models.default("writer", "A careful analysis model suited to structured reasoning and long-context review") +models.default("writer") ``` ## Complete diff --git a/crates/promptforge-api/tests/prompts/execution/real-tool-call.md b/crates/promptforge-api/tests/prompts/execution/real-tool-call.md index 71c79872..70749c8b 100644 --- a/crates/promptforge-api/tests/prompts/execution/real-tool-call.md +++ b/crates/promptforge-api/tests/prompts/execution/real-tool-call.md @@ -3,14 +3,19 @@ name: real_tool_call description: Exercise one aliased real-model tool call and continuation promptforge: 0 max_tool_iterations: 2 +tools: + ask_fixture: + want: Return one deterministic fixture value for one supplied string. +models: + writer: + description: A careful analysis model suited to structured reasoning and long-context review --- # Real Tool Call ```lua -tools.bind("ask_fixture", "Return one deterministic fixture value for one supplied string.") tools.always("ask_fixture") -models.default("writer", "A careful analysis model suited to structured reasoning and long-context review") +models.default("writer") ``` ## Call And Continue diff --git a/guide/promptforge-language-guide.md b/guide/promptforge-language-guide.md index d8c776ef..f117f80d 100644 --- a/guide/promptforge-language-guide.md +++ b/guide/promptforge-language-guide.md @@ -288,23 +288,26 @@ A prompt does not name a model directly. It describes the capability it needs, a ## Binding a model -Declare a model alias in the preamble with `models.bind`: +Declare a model role in the frontmatter with the `models` key: -````lua -models.bind('analyst', 'careful analysis', { temperature = 0.25, max_tokens = 64, thinking = false }) +````yaml +models: + analyst: + keywords: [no-thinking] + description: careful analysis ```` -The first argument is the local alias, the second is a natural-language capability description, and the third attaches invocation options such as `temperature`, `max_tokens`, `thinking`, and `context`. The options freeze at bind time and ride on every request that uses the binding. +Each key is a local label. A role carries a keyword set from a closed vocabulary, an optional `min_context` token floor, and a description. Prepare fills every declared role from the host's current model and checks the hard keywords and the context minimum against the filled model. ## The default model -The call `models.default` designates the prompt-wide default, and it comes in two forms. The multi-argument form binds and designates in one call: +The call `models.default` designates the prompt-wide default, parking a declared role by its label: ````lua -models.default("writer", "A tiny model", { thinking = false, temperature = 0 }) +models.default("writer") ```` -The single-argument form designates an already-bound alias: `models.default("writer")`. The two forms cannot be combined, and `models.default` may be called at most once per prompt, only during the live H1 pass. +The label names a role declared in the frontmatter `models` key, and `models.default` may be called at most once per prompt. ## Selecting a model for a section @@ -363,10 +366,12 @@ Models reach the outside world through tools, and a prompt controls exactly whic ## Declaring a tool -Declare a tool alias in the preamble with a natural-language capability description: +Declare a tool slot in the frontmatter with the `tools` key; a `want` description is filled by the picker at prepare, an exact global path by identity: -````lua -tools.bind('search', 'search the web') +````yaml +tools: + search: + want: search the web ```` The call `tools.bind` alone advertises nothing to the model; it only declares the alias. Binding resolves the description against the live catalog, and the failures are typed and specific: no match for the description, an ambiguous match listing the candidate identities, a duplicate alias, the same tool selected twice, or a picked tool absent from the live catalog. A capability description is resolved at most once per run, so repeated binds of the same description return the identical cached outcome, including identical failures. diff --git a/guide/src/language/06-models.md b/guide/src/language/06-models.md index d9f04db8..0adb11c3 100644 --- a/guide/src/language/06-models.md +++ b/guide/src/language/06-models.md @@ -4,23 +4,26 @@ A prompt does not name a model directly. It describes the capability it needs, a ## Binding a model -Declare a model alias in the preamble with `models.bind`: +Declare a model role in the frontmatter with the `models` key: -````lua -models.bind('analyst', 'careful analysis', { temperature = 0.25, max_tokens = 64, thinking = false }) +````yaml +models: + analyst: + keywords: [no-thinking] + description: careful analysis ```` -The first argument is the local alias, the second is a natural-language capability description, and the third attaches invocation options such as `temperature`, `max_tokens`, `thinking`, and `context`. The options freeze at bind time and ride on every request that uses the binding. +Each key is a local label. A role carries a keyword set from a closed vocabulary, an optional `min_context` token floor, and a description. Prepare fills every declared role from the host's current model and checks the hard keywords and the context minimum against the filled model. ## The default model -The call `models.default` designates the prompt-wide default, and it comes in two forms. The multi-argument form binds and designates in one call: +The call `models.default` designates the prompt-wide default, parking a declared role by its label: ````lua -models.default("writer", "A tiny model", { thinking = false, temperature = 0 }) +models.default("writer") ```` -The single-argument form designates an already-bound alias: `models.default("writer")`. The two forms cannot be combined, and `models.default` may be called at most once per prompt, only during the live H1 pass. +The label names a role declared in the frontmatter `models` key, and `models.default` may be called at most once per prompt. ## Selecting a model for a section diff --git a/guide/src/language/07-tools.md b/guide/src/language/07-tools.md index 5ac02028..6d6823cc 100644 --- a/guide/src/language/07-tools.md +++ b/guide/src/language/07-tools.md @@ -4,10 +4,12 @@ Models reach the outside world through tools, and a prompt controls exactly whic ## Declaring a tool -Declare a tool alias in the preamble with a natural-language capability description: +Declare a tool slot in the frontmatter with the `tools` key; a `want` description is filled by the picker at prepare, an exact global path by identity: -````lua -tools.bind('search', 'search the web') +````yaml +tools: + search: + want: search the web ```` The call `tools.bind` alone advertises nothing to the model; it only declares the alias. Binding resolves the description against the live catalog, and the failures are typed and specific: no match for the description, an ambiguous match listing the candidate identities, a duplicate alias, the same tool selected twice, or a picked tool absent from the live catalog. A capability description is resolved at most once per run, so repeated binds of the same description return the identical cached outcome, including identical failures. diff --git a/prompts/analyst-example.md b/prompts/analyst-example.md index 2d22cc91..98a5d85e 100644 --- a/prompts/analyst-example.md +++ b/prompts/analyst-example.md @@ -1,18 +1,18 @@ --- name: analyst_example -description: Demonstrate models.bind and models.use for careful model resolution. +description: Demonstrate frontmatter model roles and models.use for careful model selection. promptforge: 0 +models: + analyst: + min_context: 40000 + description: A model suited for careful analysis --- # Analyst Example -```lua -models.bind("analyst", "A model suited for careful analysis", { thinking = false, temperature = 0, context = 40000 }) -``` - --- -Demonstrates prompt-local model resolution. H1 `models.bind` resolves against the host's gateway catalog. A section that calls `models.use` runs every completion under that model object's frozen invocation; a section that omits `models.use` inherits the prompt-wide `models.default` model when one is declared. +Demonstrates prompt-local model selection. The frontmatter `models:` key declares the `analyst` role, filled from the host's current model at prepare. A section that calls `models.use` runs every completion under that role's bound model; a section that omits `models.use` inherits the prompt-wide `models.default` model when one is declared. ## Analyze diff --git a/prompts/greet.md b/prompts/greet.md index 7194b339..051faf8f 100644 --- a/prompts/greet.md +++ b/prompts/greet.md @@ -2,12 +2,14 @@ name: greet description: Greet the named input using a Lua-computed value promptforge: 0 +models: + writer: {} --- # Greet ```lua -models.default("writer", "A model suited for careful analysis, coding, and general assistance") +models.default("writer") ``` Computes a greeting from the input in Lua, substitutes it into the prose, and diff --git a/prompts/hello.md b/prompts/hello.md index a5bdcbd0..464798ec 100644 --- a/prompts/hello.md +++ b/prompts/hello.md @@ -2,12 +2,14 @@ name: hello description: Say hello promptforge: 0 +models: + writer: {} --- # Hello World ```lua -models.default("writer", "A model suited for careful analysis, coding, and general assistance") +models.default("writer") ``` A minimal test prompt. diff --git a/prompts/research-person.md b/prompts/research-person.md index b2a4eb77..74770fd6 100644 --- a/prompts/research-person.md +++ b/prompts/research-person.md @@ -3,14 +3,19 @@ name: research_person description: Research a person from the open web and return a concise, factual summary. promptforge: 0 max_tool_iterations: 20 +capabilities: + - promptforge/web +tools: + search: promptforge/web/search + fetch: promptforge/web/fetch +models: + researcher: {} --- # Research a Person ```lua -tools.bind("search", "Search the web and return a list of results (title, url, description).") -tools.bind("fetch", "Fetch a web page and return its main content as markdown.") -models.default("researcher", "A model suited for careful analysis, coding, and general assistance") +models.default("researcher") ``` ## Research diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index c35a1b9b..042a9158 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -1006,7 +1006,7 @@ In `promptforge-lua`: `tools.bind` removed entirely (binding is frontmatter), `m -### Step 15: Prose binding migration +### Step 15: Prose binding migration [completed] - Component: binding From 2cdd278213f3d959668124ba4ab6f9a539d104b6 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 00:53:59 -0700 Subject: [PATCH 16/30] Add promptforge/web capability packing fetch and search Add the first-party web capability: one activation unit that contributes the fetch and search tools together, since a research prompt wants both or neither. The host builds it once at registration with the gateway API root and bearer token, so an invalid root or empty token fails at startup rather than during a run. Activation clones the pre-built tools into the run's contribution, and a run that is already cancelled is refused with a cancelled error. - `Web` combines the fetch and search tools behind one Capability implementation with private state and a validating constructor; the module re-exports `FetchConfig` and `ConfigError` so hosts depend only on this crate. - `Web::new` builds both tools at registration and rejects an invalid gateway root or an empty token, so misconfiguration fails at host startup rather than at a run's prepare time. - `with_fetch_config` swaps the default fetch policy for a validated custom one, returning `ConfigError` when the HTTP client cannot be built for it. - `create` refuses an already-cancelled run with `CapabilityErrorKind::Cancelled` and otherwise contributes clones of the pre-built tools under the capability's own id. - `mod tests` covers tool id containment and wire names, construction rejection, cancelled activation, and acceptance of a custom fetch policy. Design: new facade @ crates/promptforge-web/src/lib.rs boundary: pub Design: new encapsulated-invariant @ crates/promptforge-web/src/lib.rs::Web Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- Cargo.lock | 10 + Cargo.toml | 1 + crates/promptforge-web/Cargo.toml | 24 +++ crates/promptforge-web/README.md | 21 ++ crates/promptforge-web/src/lib.rs | 195 ++++++++++++++++++ ...2026-09-13-1-capabilities-global-naming.md | 2 +- 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 crates/promptforge-web/Cargo.toml create mode 100644 crates/promptforge-web/README.md create mode 100644 crates/promptforge-web/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 765f49a4..2dca53c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4922,6 +4922,16 @@ dependencies = [ "shared-vfs", ] +[[package]] +name = "promptforge-web" +version = "0.3.0" +dependencies = [ + "promptforge-web-search", + "promptforge-webfetch", + "shared-promptforge-api", + "shared-vfs", +] + [[package]] name = "promptforge-web-search" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index e9712231..7a8f0a9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ gateway-stt = { path = "crates/gateway-stt", version = "0.3.0" } 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-web = { path = "crates/promptforge-web", version = "0.3.0" } promptforge-tool-picker = { path = "crates/promptforge-tool-picker", 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" } diff --git a/crates/promptforge-web/Cargo.toml b/crates/promptforge-web/Cargo.toml new file mode 100644 index 00000000..eba15453 --- /dev/null +++ b/crates/promptforge-web/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "promptforge-web" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "PromptForge promptforge/web capability: web fetch and search tools in one pack" +readme = "README.md" +keywords = ["promptforge", "llm", "tools", "web", "ai"] +categories = ["web-programming::http-client", "api-bindings"] +documentation = "https://cppalliance.github.io/promptforge/" + +[dependencies] +shared-promptforge-api.workspace = true +promptforge-webfetch.workspace = true +promptforge-web-search.workspace = true + +[dev-dependencies] +shared-vfs.workspace = true + +[lints] +workspace = true diff --git a/crates/promptforge-web/README.md b/crates/promptforge-web/README.md new file mode 100644 index 00000000..8878373f --- /dev/null +++ b/crates/promptforge-web/README.md @@ -0,0 +1,21 @@ +# promptforge-web + +The first-party `promptforge/web` capability: one activation unit +contributing the `promptforge/web/fetch` and `promptforge/web/search` +tools. A research prompt wants both or neither, so a prompt declares one +frontmatter line (`capabilities: [promptforge/web]`) and gets the pair. + +```rust +use promptforge_web::Web; +use shared_promptforge_api::capabilities::Capability; + +let capability = Web::new("https://gateway.example.com/v1", "bearer-token")?; +assert_eq!(capability.id().to_string(), "promptforge/web"); +# Ok::<(), shared_promptforge_api::tools::ToolError>(()) +``` + +The fetch tool enforces the crate's SSRF policy (see `promptforge-webfetch`); +the search tool proxies through the gateway so the vendor credential never +leaves the server (see `promptforge-web-search`). The host supplies the +gateway API root and bearer token when it builds the capability at +registration; the prompt never sees them. diff --git a/crates/promptforge-web/src/lib.rs b/crates/promptforge-web/src/lib.rs new file mode 100644 index 00000000..5548b2de --- /dev/null +++ b/crates/promptforge-web/src/lib.rs @@ -0,0 +1,195 @@ +//! The `promptforge/web` capability: web fetch and search in one pack. +//! +//! A research prompt wants both tools or neither, so the first-party web +//! capability activates as one frontmatter line +//! (`capabilities: [promptforge/web]`) and contributes +//! `promptforge/web/fetch` and `promptforge/web/search` - the tools formerly +//! shipped as the separate `promptforge-webfetch` and `promptforge-web-search` +//! packs, combined under the single capability their ids already name. +//! +//! The host builds the capability once at registration with the gateway's API +//! root and bearer token (the search tool proxies through the gateway so the +//! vendor credential never leaves the server) and an optional fetch policy; +//! the prompt never sees either. Activation clones the pre-built tools into +//! the run's [`Contribution`]. + +use std::sync::Arc; + +use shared_promptforge_api::capabilities::{ + Capability, CapabilityError, CapabilityErrorKind, CapabilityId, Contribution, RunServices, +}; +use shared_promptforge_api::tools::ToolError; + +use promptforge_web_search::WebSearch; +use promptforge_webfetch::WebFetch; +pub use promptforge_webfetch::{ConfigError, FetchConfig}; + +/// The first-party `promptforge/web` capability. +/// +/// Contributes `promptforge/web/fetch` (a hardened page fetch rendering to +/// markdown) and `promptforge/web/search` (a search proxy through the +/// gateway). Both tools are built at construction, so a bad gateway root or +/// an empty token fails here - at host startup - rather than at a run's +/// prepare time. +/// +/// # Examples +/// ``` +/// use promptforge_web::Web; +/// use shared_promptforge_api::capabilities::Capability; +/// +/// let capability = Web::new("https://gateway.example.com/v1", "bearer-token")?; +/// assert_eq!(capability.id().to_string(), "promptforge/web"); +/// # Ok::<(), shared_promptforge_api::tools::ToolError>(()) +/// ``` +#[derive(Debug, Clone)] +pub struct Web { + /// The stable identity, `promptforge/web`. + id: CapabilityId, + /// The fetch tool, built over its validated policy. + fetch: WebFetch, + /// The search tool, bound to the gateway root and bearer token. + search: WebSearch, +} + +impl Web { + /// Builds the capability over the default fetch policy. + /// + /// `base_url` is the gateway's OpenAI-shaped API root (for example + /// `https://gateway.example.com/v1`) and `token` the shared bearer token; + /// both are validated here. + /// + /// # Errors + /// Returns the search tool's [`ToolError`] when `base_url` is not a valid + /// gateway API root or `token` is empty. + pub fn new(base_url: &str, token: impl Into) -> Result { + Ok(Web { + id: CapabilityId::from_validated("promptforge/web"), + fetch: WebFetch::new(), + search: WebSearch::new(base_url, token)?, + }) + } + + /// Replaces the default fetch policy with a validated custom one. + /// + /// # Errors + /// Returns [`ConfigError`] if the HTTP client cannot be built for + /// `config` (for example a TLS backend that fails to initialize). + pub fn with_fetch_config(mut self, config: FetchConfig) -> Result { + self.fetch = WebFetch::try_with_config(config)?; + Ok(self) + } +} + +impl Capability for Web { + fn id(&self) -> &CapabilityId { + &self.id + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Capability trait fixes this return type to &str, so the &'static str suggestion cannot be applied" + )] + fn description(&self) -> &str { + "Fetch a web page as markdown and search the web through the gateway." + } + + fn create(&self, services: &RunServices) -> Result { + if services.cancel.is_cancelled() { + return Err( + CapabilityError::message("promptforge/web: the run was cancelled") + .with_kind(CapabilityErrorKind::Cancelled), + ); + } + Ok(Contribution { + tools: vec![Arc::new(self.fetch.clone()), Arc::new(self.search.clone())], + }) + } +} + +#[cfg(test)] +mod tests { + use shared_promptforge_api::cancel::CancelHandle; + use shared_promptforge_api::capabilities::{ + Capability, CapabilityErrorKind, CapabilityId, RunServices, + }; + use shared_promptforge_api::tools::ToolId; + + use crate::Web; + + /// Fresh run services over an empty VFS and a live cancel handle. + fn services() -> RunServices { + RunServices::new(shared_vfs::VfsRef::builder().build(), CancelHandle::new()) + } + + #[test] + fn activating_the_capability_contributes_both_tools_under_its_full_id() { + let capability = Web::new("http://localhost", "tok").expect("valid configuration"); + assert_eq!( + capability.id(), + &CapabilityId::parse("promptforge/web").expect("valid capability id") + ); + + let contribution = capability.create(&services()).expect("activation succeeds"); + + let mut ids: Vec = contribution.tools.iter().map(|tool| tool.id()).collect(); + ids.sort(); + assert_eq!( + ids, + vec![ + ToolId::parse("promptforge/web/fetch").expect("valid tool id"), + ToolId::parse("promptforge/web/search").expect("valid tool id"), + ] + ); + for tool in &contribution.tools { + assert!( + capability.id().contains(&tool.id()), + "every contributed tool lives under the capability's id: {}", + tool.id() + ); + } + + let mut wire_names: Vec<&str> = contribution + .tools + .iter() + .map(|tool| tool.wire_name()) + .collect(); + wire_names.sort_unstable(); + assert_eq!(wire_names, ["web_fetch", "web_search"]); + } + + #[test] + fn construction_rejects_an_invalid_gateway_root_or_empty_token() { + assert!(Web::new("not-a-url", "tok").is_err()); + assert!(Web::new("http://user:pass@host/v1", "tok").is_err()); + assert!(Web::new("http://localhost", "").is_err()); + } + + #[test] + fn activation_on_a_cancelled_run_fails_as_cancelled() { + let capability = Web::new("http://localhost", "tok").expect("valid configuration"); + let cancel = CancelHandle::new(); + cancel.cancel(); + let services = RunServices::new(shared_vfs::VfsRef::builder().build(), cancel); + + let err = capability + .create(&services) + .expect_err("a cancelled run must not activate"); + assert_eq!(err.kind(), CapabilityErrorKind::Cancelled); + assert!(err.is_cancelled()); + } + + #[test] + fn a_custom_fetch_policy_is_accepted() { + let policy = promptforge_webfetch::FetchConfig::builder() + .max_chars(10_000) + .build() + .expect("valid policy"); + let capability = Web::new("http://localhost", "tok") + .expect("valid configuration") + .with_fetch_config(policy) + .expect("the custom policy builds a fetch tool"); + + let contribution = capability.create(&services()).expect("activation succeeds"); + assert_eq!(contribution.tools.len(), 2); + } +} diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 042a9158..a033edc1 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -1016,7 +1016,7 @@ Migrate the 4 shipped prompts, the executor fixtures, the 3 fenced guide example -### Step 16: The promptforge/web capability +### Step 16: The promptforge/web capability [completed] - Component: first-party From e7d8272d1d12de8533e8b179bf39e6da36fc7b58 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 01:25:47 -0700 Subject: [PATCH 17/30] Wire Workshop chat onto the shared capability environment The Workshop's chat sessions now prepare against one shared environment carrying the first-party web capability built from the gateway's root and key, rebuilt when the gateway generation changes so a replacement gateway reaches the contributed tools. Each run binds the dropdown's model selection at launch and keeps that binding for its whole life, so a selection change takes effect on the next run rather than mid-turn. The built-in chat prompt declares its capability, tool slots, and model role in frontmatter instead of re-reading the selection every turn. When the gateway settings cannot build the capability, the relaunch fails with a reported error instead of running degraded. - `crates/workshop-sessions/src/agents/environment.rs` builds the shared model-free environment with the web capability registered, and resolves the launch-time model: the dropdown selection, the catalog's first chat model when nothing is selected yet, and a fallback descriptor under an 8192-token context window when the catalog fetch fails. - `EffectExecutor` stores the shared environment beside the gateway generation it was built from, rebuilds both when the generation changes, and reports a failure instead of launching when the capability cannot be built. - `run_markdown_agent` receives the environment and gateway snapshot as parameters and binds the resolved model into the run context; the fresh per-run environment construction is gone. - `chat.md` declares one capability, two exact tool slots, and the chat model role in frontmatter, and its loop advertises both web tools and runs on the bound model. - `crates/promptforge-api/src/client.rs` re-exports the typed catalog fetch so the host can resolve a selection without the gateway's model list crossing the executor interface. - `ui().selected_model` and the raw-id `models.get` lookup leave the chat loop; nothing in a live run observes a selection change anymore. Design: extends facade @ crates/promptforge-api/src/client.rs boundary: pub Design: extends facade @ crates/workshop-sessions/src/lib.rs boundary: pub Design: new constructor-injection @ crates/workshop-sessions/src/agents/supervisor/effects.rs::run_markdown_agent deps: Environment,GatewaySnapshot,MarkdownRunParts,ModelClient,RunId,str Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- Cargo.lock | 1 + crates/promptforge-api/src/client.rs | 9 +- crates/workshop-server/tests/it/chat_gate.rs | 24 +- .../tests/it/chat_gate/lifecycle.rs | 59 ++- .../tests/it/chat_gate/recovery.rs | 56 ++- crates/workshop-sessions/Cargo.toml | 1 + crates/workshop-sessions/agents/chat.md | 22 +- crates/workshop-sessions/src/agents.rs | 2 + .../src/agents/environment.rs | 424 ++++++++++++++++++ .../src/agents/supervisor/effects.rs | 77 +++- crates/workshop-sessions/src/agents/tests.rs | 56 ++- crates/workshop-sessions/src/lib.rs | 2 +- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 13 files changed, 651 insertions(+), 84 deletions(-) create mode 100644 crates/workshop-sessions/src/agents/environment.rs diff --git a/Cargo.lock b/Cargo.lock index 2dca53c3..daa64ff1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8631,6 +8631,7 @@ dependencies = [ "axum", "futures-util", "promptforge-api", + "promptforge-web", "rand 0.9.5", "serde", "serde_json", diff --git a/crates/promptforge-api/src/client.rs b/crates/promptforge-api/src/client.rs index 40c3c49b..a8ac150d 100644 --- a/crates/promptforge-api/src/client.rs +++ b/crates/promptforge-api/src/client.rs @@ -8,7 +8,10 @@ //! 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 //! executor never sees it. Point `PROMPTFORGE_GATEWAY_URL` at a local server -//! or another gateway to retarget it. +//! or another gateway to retarget it. [`fetch_model_catalog`] reads the +//! gateway's typed model list for host-side concerns (the Workshop dropdown +//! and its selection resolution); the list never crosses into the +//! environment an executor run prepares against. //! //! The implementation lives in the `promptforge-model-client` crate and is //! re-exported here: hosts pass a [`GatewayClient`] to @@ -16,7 +19,9 @@ //! [`CompletionError`]. pub use promptforge_model_client::client::{GatewayClient, GatewayEndpoint, SecretString}; -pub use promptforge_model_client::model::{CompletionError, CompletionErrorKind}; +pub use promptforge_model_client::model::{ + CompletionError, CompletionErrorKind, fetch_model_catalog, +}; pub(crate) use promptforge_model_client::client::{ Completion, CompletionResult, Message, StreamDelta, ToolCall, ToolSchema, diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index 2f006035..dc073eb0 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -28,9 +28,10 @@ use serde_json::json; use tokio::sync::broadcast; use promptforge_api::client::{GatewayClient as ModelClient, GatewayEndpoint, SecretString}; -use promptforge_api::{Environment, Prompt, RunContext, RunResult}; +use promptforge_api::{Prompt, RunContext, RunResult}; use shared_promptforge_api::cancel::CancelHandle; use shared_promptforge_api::events::{EventLog as _, RuntimeEventKind}; +use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; use shared_promptforge_api::observe::Observer; use workshop_server::fixtures::{gateway_updater, replace_gateway, state_with_gateway}; use workshop_server::{ @@ -327,8 +328,12 @@ struct RestoredChat { /// The relaunch half of the restart gate: the supervisor's own pieces - /// the session's wait registry behind the generic input broker, the -/// embedded chat prompt, and a client aimed at the mock gateway - run on -/// the unified runtime over the restored log. +/// embedded chat prompt, the shared session environment carrying the +/// first-party capabilities, and a client aimed at the mock gateway - run +/// on the unified runtime over the restored log. The context carries the +/// current model directly: the supervisor resolves the dropdown's +/// selection at launch, and this harness drives the run beneath that +/// seam. fn spawn_restored_chat( restored: &Arc, session: &str, @@ -343,15 +348,20 @@ fn spawn_restored_chat( ); let cancel = CancelHandle::new(); let observer: Arc = restored.clone(); - let env = Environment::new(); + let env = workshop_sessions::session_environment(gateway_url, "test-key") + .expect("the mock gateway shape builds the session environment"); + let model = ModelDescriptor::new( + ModelId::gateway("test-model").expect("the test model id is valid"), + "test model", + std::num::NonZeroU32::new(8192).expect("8192 is non-zero"), + ThinkingMode::Never, + ); let ctx = RunContext::new(session.to_owned()) .observer(Arc::clone(&observer)) .client(client) .cancel(cancel.clone()) .input_broker(broker) - .ui(Arc::new( - || json!({ "selected_model": "test-model", "workspace_root": serde_json::Value::Null }), - )); + .model(model); let execution = session.to_owned(); let run = tokio::spawn(async move { let result = async { diff --git a/crates/workshop-server/tests/it/chat_gate/lifecycle.rs b/crates/workshop-server/tests/it/chat_gate/lifecycle.rs index 4f6f5950..21566905 100644 --- a/crates/workshop-server/tests/it/chat_gate/lifecycle.rs +++ b/crates/workshop-server/tests/it/chat_gate/lifecycle.rs @@ -1,8 +1,9 @@ -/// GATE 3 - model switch. Current-chat behavior: selecting another model -/// takes effect on the next turn, and the reply is attributed to the -/// model that produced it. +/// GATE 3 - model switch. Current-chat behavior: the run's model is the +/// dropdown selection bound at launch, so selecting another model leaves +/// the live run untouched and takes effect on the next run; the reply is +/// attributed to the model that produced it. #[tokio::test] -async fn gate_model_switch_takes_effect_next_turn_with_attribution() { +async fn gate_model_switch_takes_effect_on_the_next_run_with_attribution() { let server = spawn_chat_server(&["model-a", "model-b"]).await; let mut socket = connect_chat(&server.ws_base).await; let _session = launch_chat(&mut socket).await; @@ -22,20 +23,39 @@ async fn gate_model_switch_takes_effect_next_turn_with_attribution() { .set_selected("model-b") .expect("model-b is in the retained catalog"); + // The live run's binding is frozen at launch: the next turn still + // runs on the launch-time model. let token = wait_after(&mut socket, &turn).await; answer(&mut socket, &token, "two").await; let turn = collect_turn(&mut socket).await; let reply = turn.events.last().expect("the second turn completes"); + assert_eq!( + reply["event"]["model"], "model-a", + "a selection change never reaches a run already launched" + ); + + // The next run binds the live selection: an operator cancel retires + // the waiting run, and the relaunch prepares against model-b. + let _waiting = wait_after(&mut socket, &turn).await; + socket.send_json(&json!({ "type": "cancel" })).await; + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "three").await; + let turn = collect_turn(&mut socket).await; + let reply = turn.events.last().expect("the third turn completes"); assert_eq!( reply["event"]["model"], "model-b", - "the switch takes effect next turn; the reply event carries the new model id" + "the relaunched run binds the new selection; the reply event carries its id" ); { let requests = server.captured.lock().expect("the capture lock is healthy"); assert_eq!(requests[0]["model"], "model-a"); assert_eq!( - requests[1]["model"], "model-b", - "the request itself names the newly selected model" + requests[1]["model"], "model-a", + "the frozen run keeps its launch-time model after the switch" + ); + assert_eq!( + requests[2]["model"], "model-b", + "the switch takes effect on the next run's request" ); } socket.close().await; @@ -111,8 +131,8 @@ async fn gate_delayed_catalog_starts_chat_only_after_a_chat_model_arrives() { } /// GATE 9 - catalog replacement during a profile switch. The supervisor -/// relaunches on the new generation, and the relaunched run reads the new -/// selection from its fresh `ui()` snapshot. The message list starts +/// relaunches on the new generation, and the relaunched run binds the new +/// selection at launch. The message list starts /// fresh: history lives in the section's Lua state until the deferred /// persistence work lands. #[tokio::test] @@ -167,10 +187,9 @@ async fn gate_profile_switch_relaunches_chat_on_the_new_catalog() { /// GATE 10 - accepted-input replacement race. Catalog retirement waits /// until the in-flight turn settles, then relaunches on the new -/// generation. On the unified runtime the raced turn reads its model from -/// the fresh `ui()` snapshot - the raw-id `models.get` hack - so it -/// dispatches once against the live selection and completes; the accepted -/// input is recorded exactly once. +/// generation. On the unified runtime the raced turn's model was frozen +/// at launch, so it dispatches once against the launch-time selection and +/// completes; the accepted input is recorded exactly once. #[tokio::test] async fn gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_once() { let server = spawn_chat_server(&["model-a"]).await; @@ -202,11 +221,11 @@ async fn gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_onc .expect("the launched session remains registered") .expect("the accepted input resumes its original run"); - // The raced turn dispatches against the live selection and completes; - // its settlement retires the run. Every wait the retiring run opens is - // answered harmlessly (its run is cancelled before the answer can - // dispatch) or cancelled outright; the relaunched run's wait runs the - // next turn. + // The raced turn dispatches against its launch-frozen binding and + // completes; its settlement retires the run. Every wait the retiring + // run opens is answered harmlessly (its run is cancelled before the + // answer can dispatch) or cancelled outright; the relaunched run's + // wait runs the next turn, bound to the live selection. let mut accepted_events = 0; let mut announced: Vec = Vec::new(); let second = tokio::time::timeout(Duration::from_secs(10), async { @@ -253,8 +272,8 @@ async fn gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_onc let requests = server.captured.lock().expect("the capture lock is healthy"); assert_eq!(requests.len(), 2, "the raced turn and the recovery turn each dispatch once"); assert_eq!( - requests[0]["model"], "model-b", - "the raced turn reads the live selection through the raw-id hack" + requests[0]["model"], "model-a", + "the raced turn runs on its launch-frozen binding, never the live selection" ); assert_eq!( role_content_pairs(&requests[0]), diff --git a/crates/workshop-server/tests/it/chat_gate/recovery.rs b/crates/workshop-server/tests/it/chat_gate/recovery.rs index 9e4fe402..d6f417b6 100644 --- a/crates/workshop-server/tests/it/chat_gate/recovery.rs +++ b/crates/workshop-server/tests/it/chat_gate/recovery.rs @@ -200,14 +200,13 @@ async fn gate_model_failure_surfaces_an_error_and_the_next_input_works() { socket.close().await; } -/// GATE 7 - selection-loss recovery. A selection can vanish after the -/// browser accepted an input but before the built-in reads its fresh -/// `ui()` snapshot. The missing selection skips the model call silently - -/// no error, no request - and the loop returns to input with the accepted -/// text retained in its message list, so the next valid selection answers -/// both. +/// GATE 7 - selection-loss recovery, unified-runtime semantics: the run's +/// model is the dropdown selection bound at launch, so a selection that +/// vanishes mid-turn no longer skips anything - the frozen binding carries +/// the raced turn to completion, and the same run keeps serving turns +/// until a catalog replacement retires it. #[tokio::test] -async fn gate_selection_loss_skips_the_turn_and_recovers_after_selection() { +async fn gate_selection_loss_leaves_the_runs_frozen_binding_untouched() { let server = spawn_chat_server(&["test-model"]).await; let mut socket = connect_chat(&server.ws_base).await; let session = launch_chat(&mut socket).await; @@ -231,18 +230,10 @@ async fn gate_selection_loss_skips_the_turn_and_recovers_after_selection() { .expect("the launched session remains registered") .expect("the submitted input completes its live wait"); - // No error frame may surface (next_wait_token refuses one), no request - // may leave: the skipped turn simply returns to input. - let fresh = next_wait_token(&mut socket).await; - assert_eq!( - server - .captured - .lock() - .expect("the capture lock is healthy") - .len(), - 0, - "a missing selection never reaches the gateway" - ); + // The selection is gone, but the run's binding was frozen at launch: + // the raced turn dispatches and completes, and no error frame surfaces. + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:accepted before loss"); server .state @@ -253,28 +244,31 @@ async fn gate_selection_loss_skips_the_turn_and_recovers_after_selection() { .menu() .set_selected("test-model") .expect("the retained model can be selected for recovery"); - answer(&mut socket, &fresh, "recovered after selection").await; + let token = wait_after(&mut socket, &turn).await; + answer(&mut socket, &token, "recovered after selection").await; let turn = collect_turn(&mut socket).await; assert_eq!( delta_text(&turn), - "echo:accepted before loss\n\nrecovered after selection", - "the next input completes after selection becomes valid" + "echo:recovered after selection", + "the same run answers the next input on its frozen binding" ); { let requests = server.captured.lock().expect("the capture lock is healthy"); assert_eq!( requests.len(), - 1, - "only the recovered turn reaches the gateway" + 2, + "both turns reach the gateway on the frozen model" ); + assert_eq!(requests[0]["model"], "test-model"); + assert_eq!(requests[1]["model"], "test-model"); assert_eq!( - role_content_pairs(&requests[0]), - vec![pair( - "user", - "accepted before loss\n\nrecovered after selection" - )], - "the skipped input was retained in the message list; the projection joins \ - the two consecutive user utterances with a blank line" + role_content_pairs(&requests[1]), + vec![ + pair("user", "accepted before loss"), + pair("assistant", "echo:accepted before loss"), + pair("user", "recovered after selection"), + ], + "the same run retains its message list across the selection loss" ); } socket.close().await; diff --git a/crates/workshop-sessions/Cargo.toml b/crates/workshop-sessions/Cargo.toml index 129e52bb..04b8ac92 100644 --- a/crates/workshop-sessions/Cargo.toml +++ b/crates/workshop-sessions/Cargo.toml @@ -17,6 +17,7 @@ async-trait.workspace = true axum.workspace = true futures-util.workspace = true promptforge-api.workspace = true +promptforge-web.workspace = true shared-promptforge-api.workspace = true rand.workspace = true serde.workspace = true diff --git a/crates/workshop-sessions/agents/chat.md b/crates/workshop-sessions/agents/chat.md index 8ff0e24a..67918599 100644 --- a/crates/workshop-sessions/agents/chat.md +++ b/crates/workshop-sessions/agents/chat.md @@ -2,14 +2,27 @@ name: chat description: The built-in Workshop chat agent on the unified runtime. promptforge: 0 +capabilities: + - promptforge/web +tools: + fetch: promptforge/web/fetch + search: promptforge/web/search +models: + chat: {} --- # 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. +across turns; the model is the dropdown's selection bound at run launch, so +a selection change takes effect on the next run. + +```lua +models.default("chat") +tools.always("fetch") +tools.always("search") +``` ## Conversation @@ -21,9 +34,6 @@ while true do return end history:user(text) - local selected = ui().selected_model - if selected then - pcall(function() return models.loop(models.get(selected), history) end) - end + pcall(function() return models.loop(history) end) end ``` diff --git a/crates/workshop-sessions/src/agents.rs b/crates/workshop-sessions/src/agents.rs index f81e47b9..c61a7648 100644 --- a/crates/workshop-sessions/src/agents.rs +++ b/crates/workshop-sessions/src/agents.rs @@ -25,6 +25,7 @@ //! resumes, and the socket derives the same count from the event sequence //! itself, so both sides agree without sharing more than the log. +mod environment; mod lifecycle; mod session; pub(crate) mod socket; @@ -48,6 +49,7 @@ use crate::input::WaitRegistry; use self::lifecycle::RunLifecycle; +pub use environment::session_environment; pub(crate) use session::{AgentDelta, AgentSession, AgentSource, SessionObserver}; pub(crate) use session::{delta_stamp, reply_stamp, ui_provider}; diff --git a/crates/workshop-sessions/src/agents/environment.rs b/crates/workshop-sessions/src/agents/environment.rs new file mode 100644 index 00000000..a62f8a21 --- /dev/null +++ b/crates/workshop-sessions/src/agents/environment.rs @@ -0,0 +1,424 @@ +//! The session run's environment and current model: the shared model-free +//! [`Environment`] every session run prepares against, and the launch-time +//! resolution of the dropdown's current model into the per-run context. + +use std::num::NonZeroU32; +use std::sync::Arc; + +use promptforge_api::client::fetch_model_catalog; +use promptforge_api::{CapabilityRegistry, Environment}; +use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; + +use super::SessionHost; + +/// The context window a selection resolved without catalog metadata +/// records: a conservative default keeps the compactor precheck safe, +/// mirroring the raw-id binding's fallback. +const FALLBACK_CONTEXT: NonZeroU32 = match NonZeroU32::new(8192) { + Some(value) => value, + None => unreachable!(), +}; + +/// Builds the sessions' shared environment for one gateway generation: +/// model-free (the gateway's model list feeds the dropdown UI and never +/// crosses this interface), carrying the first-party capabilities built +/// from the gateway's API root and bearer - today `promptforge/web`. One +/// environment is shared across the runs of one gateway generation and +/// rebuilt when the generation changes, so a replacement gateway's root +/// and key reach the contributed tools. +/// +/// Returns `None` - reported like an unusable model client - when the +/// gateway root or key cannot build the capability. +#[must_use] +pub fn session_environment(base_url: &str, api_key: &str) -> Option { + let root = format!("{}/v1", base_url.trim_end_matches('/')); + let web = match promptforge_web::Web::new(&root, api_key) { + Ok(web) => web, + Err(error) => { + tracing::warn!(%error, "agent sessions degraded: the gateway cannot build promptforge/web"); + return None; + } + }; + let mut registry = CapabilityRegistry::new(); + if registry.register(Arc::new(web)).is_err() { + // A single registration cannot collide; the registry's error is + // defensive on this path. + return None; + } + Some(Environment::new().registry(registry)) +} + +/// Resolves the dropdown's current model for one run's context. The +/// selection is read at launch, so a selection change takes effect on the +/// next run. A launch with no selection yet - the boot window before the +/// menu's own auto-select settles - binds the retained catalog's first +/// chat-capable model, the same fallback the menu applies. The typed +/// descriptor comes from the gateway's model list through +/// [`fetch_model_catalog`]; when the fetch fails or the selection is +/// absent from it, a minimal descriptor under the fallback context window +/// keeps the run on the selected id, mirroring the raw-id binding's +/// fallback. +/// +/// Returns `None` only when neither a selection nor a catalog model +/// exists, or the id is not representable; the prompt's declared roles +/// then stay unbound. +pub(crate) async fn current_model( + host: &SessionHost, + base_url: &str, + api_key: &str, +) -> Option { + let selected = host + .menu() + .latest() + .and_then(|snapshot| snapshot.selected_model) + .or_else(|| { + host.catalog() + .latest_chat()? + .models + .first()? + .get("id")? + .as_str() + .map(str::to_owned) + })?; + let id = match ModelId::gateway(&selected) { + Ok(id) => id, + Err(error) => { + tracing::warn!(%error, "the selected model id is invalid"); + return None; + } + }; + let root = format!("{}/v1", base_url.trim_end_matches('/')); + let fetched = match fetch_model_catalog(&root, api_key).await { + Ok(catalog) => catalog.get(&id).cloned(), + Err(error) => { + tracing::warn!(%error, "the model catalog fetch failed; the selection binds under the fallback descriptor"); + None + } + }; + Some(fetched.unwrap_or_else(|| { + tracing::debug!(model = %selected, "binding the selection under the fallback descriptor"); + ModelDescriptor::new(id, "", FALLBACK_CONTEXT, ThinkingMode::Never) + })) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + use std::time::Duration; + + use workshop_gateway::GatewayBinding; + use workshop_menu::{CatalogBus, MenuBus}; + use workshop_registry::Registry; + use workshop_support::ReconnectBackoff; + + use super::super::AgentSessions; + use super::*; + + /// A host whose menu and catalog hold one chat-capable model, with + /// the selection applied only when `selected` is set. + fn host_with_catalog(selected: bool) -> SessionHost { + let catalog = CatalogBus::new(); + catalog.publish(vec![ + serde_json::json!({ "id": "test-model", "object": "model" }), + ]); + let menu = MenuBus::new(catalog.clone(), None); + if selected { + menu.set_selected("test-model") + .expect("the id is in the catalog"); + } + SessionHost::new(Registry::new(), ReconnectBackoff::new(), menu, catalog) + } + + /// Serves the typed catalog entry the fetch resolves through. + async fn spawn_models_gateway() -> String { + let app = axum::Router::new().route( + "/v1/models", + axum::routing::get(|| async { + axum::Json(serde_json::json!({ + "object": "list", + "data": [{ "id": "test-model", "description": "fetched", "context": 4096, "thinking": "switchable" }], + })) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the mock gateway binds"); + let addr = listener.local_addr().expect("the mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("the mock serves"); + }); + format!("http://{addr}") + } + + #[test] + fn the_session_environment_builds_only_from_usable_gateway_settings() { + assert!( + session_environment("http://127.0.0.1:1", "k").is_some(), + "a well-shaped root and key build the environment" + ); + assert!( + session_environment("http://127.0.0.1:1", "").is_none(), + "an empty key cannot authenticate the search proxy" + ); + assert!(session_environment("not a url", "k").is_none()); + } + + #[tokio::test] + async fn the_selection_resolves_through_the_fetched_catalog() { + let base_url = spawn_models_gateway().await; + let host = host_with_catalog(true); + let model = current_model(&host, &base_url, "k") + .await + .expect("the selection resolves"); + assert_eq!(model.id().name(), "test-model"); + assert_eq!( + model.context(), + NonZeroU32::new(4096).expect("4096 is non-zero"), + "the fetched descriptor wins over the fallback" + ); + assert_eq!(model.thinking(), ThinkingMode::Switchable); + } + + #[tokio::test] + async fn a_failed_catalog_fetch_binds_the_fallback_descriptor() { + // Port 1 refuses the connection: the fetch fails fast. + let host = host_with_catalog(true); + let model = current_model(&host, "http://127.0.0.1:1", "k") + .await + .expect("the fallback keeps the selected id"); + assert_eq!(model.id().name(), "test-model"); + assert_eq!(model.context(), FALLBACK_CONTEXT); + assert_eq!(model.thinking(), ThinkingMode::Never); + } + + #[tokio::test] + async fn a_launch_without_a_selection_binds_the_first_catalog_model() { + let host = host_with_catalog(false); + let model = current_model(&host, "http://127.0.0.1:1", "k") + .await + .expect("the catalog's first model stands in"); + assert_eq!(model.id().name(), "test-model"); + } + + #[tokio::test] + async fn no_selection_and_no_catalog_means_no_model() { + let catalog = CatalogBus::new(); + let menu = MenuBus::new(catalog.clone(), None); + let host = SessionHost::new(Registry::new(), ReconnectBackoff::new(), menu, catalog); + assert!( + current_model(&host, "http://127.0.0.1:1", "k") + .await + .is_none() + ); + } + + /// One SSE data line carrying `event`. + fn sse_line(event: &serde_json::Value) -> String { + format!("data: {event}\n\n") + } + + /// The mock's first completion: the model calls the `search` slot. + fn sse_search_call() -> String { + let call = serde_json::json!({ + "object": "chat.completion.chunk", + "model": "test-model", + "choices": [{ "index": 0, "delta": { "tool_calls": [{ + "index": 0, "id": "call_1", "type": "function", + "function": { "name": "search", "arguments": "{\"query\":\"promptforge\"}" } + }] }, "finish_reason": null }], + }); + let finish = serde_json::json!({ + "object": "chat.completion.chunk", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }], + }); + sse_line(&call) + &sse_line(&finish) + "data: [DONE]\n\n" + } + + /// The mock's later completions: a terminal text reply. + fn sse_text_reply() -> String { + let chunk = serde_json::json!({ + "object": "chat.completion.chunk", + "model": "test-model", + "choices": [{ "index": 0, "delta": { "content": "found it" }, "finish_reason": null }], + }); + let finish = serde_json::json!({ + "object": "chat.completion.chunk", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }], + }); + sse_line(&chunk) + &sse_line(&finish) + "data: [DONE]\n\n" + } + + /// Polls until the session's run opens an input wait and returns its token. + async fn next_wait(sessions: &AgentSessions, id: &str) -> String { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(token) = sessions + .unresolved_waits(id) + .and_then(|tokens| tokens.first().cloned()) + { + return token; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the run opens an input wait") + } + + /// The mock gateway behind the end-to-end chat session: scripted + /// completions (a `search` tool call, then text), the search endpoint the + /// activated capability proxies to, and the typed model catalog the + /// launch-time selection resolution fetches. + struct ChatGateway { + /// The mock's `http://` base URL. + base_url: String, + /// Every completion request body, in arrival order. + completions: Arc>>, + /// Every search request body plus its Authorization header. + searches: Arc>>, + } + + /// Binds the mock gateway on a loopback ephemeral port. + async fn spawn_chat_gateway() -> ChatGateway { + use axum::response::IntoResponse; + use axum::routing::{get, post}; + + let completions = Arc::new(Mutex::new(Vec::new())); + let searches = Arc::new(Mutex::new(Vec::new())); + let completion_log = Arc::clone(&completions); + let search_log = Arc::clone(&searches); + let gateway = axum::Router::new() + .route( + "/v1/chat/completions", + post(move |body: String| { + let log = Arc::clone(&completion_log); + async move { + let body: serde_json::Value = + serde_json::from_str(&body).expect("the request is JSON"); + let call = { + let mut log = log.lock().expect("the capture lock is healthy"); + log.push(body); + log.len() + }; + let sse = if call == 1 { sse_search_call() } else { sse_text_reply() }; + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + sse, + ) + .into_response() + } + }), + ) + .route( + "/v1/tools/web_search", + post(move |headers: axum::http::HeaderMap, body: String| { + let log = Arc::clone(&search_log); + async move { + let mut captured: serde_json::Value = + serde_json::from_str(&body).expect("the search request is JSON"); + captured["authorization"] = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned() + .into(); + log.lock().expect("the capture lock is healthy").push(captured); + axum::Json(serde_json::json!({ + "results": [{ "url": "https://example.com", "title": "t", "description": "d" }] + })) + .into_response() + } + }), + ) + .route( + "/v1/models", + get(|| async { + axum::Json(serde_json::json!({ + "object": "list", + "data": [{ "id": "test-model", "description": "d", "context": 8192, "thinking": "never" }], + })) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the mock gateway binds"); + let addr = listener.local_addr().expect("the mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, gateway) + .await + .expect("the mock serves"); + }); + ChatGateway { + base_url: format!("http://{addr}"), + completions, + searches, + } + } + + #[tokio::test] + async fn a_chat_session_activates_the_web_capability_and_calls_search_end_to_end() { + let gateway = spawn_chat_gateway().await; + let catalog = CatalogBus::new(); + catalog.publish(vec![ + serde_json::json!({ "id": "test-model", "object": "model" }), + ]); + let menu = MenuBus::new(catalog.clone(), None); + menu.set_selected("test-model") + .expect("the id is in the catalog"); + let dir = tempfile::TempDir::new().expect("tempdir"); + let sessions = AgentSessions::new( + dir.path().join("missing-agents"), + dir.path().join("sessions"), + GatewayBinding::new(&gateway.base_url, "test-key").expect("the binding builds"), + SessionHost::new(Registry::new(), ReconnectBackoff::new(), menu, catalog), + ); + let session = sessions.launch("chat").expect("the built-in chat launches"); + + let token = next_wait(&sessions, &session.id).await; + session + .waits + .complete(&token, "search the web".to_owned()) + .expect("the wait completes"); + // The loop's return to input proves the whole turn settled: the model + // round, the tool call through the activated capability, and the + // terminal reply. + let _settled = next_wait(&sessions, &session.id).await; + + let completions = gateway + .completions + .lock() + .expect("the capture lock is healthy"); + assert_eq!(completions.len(), 2, "the turn is two model rounds"); + assert_eq!(completions[0]["model"], "test-model"); + let advertised: Vec<&str> = completions[0]["tools"] + .as_array() + .expect("the filled slots advertise on the wire") + .iter() + .filter_map(|tool| tool["function"]["name"].as_str()) + .collect(); + assert!( + advertised.contains(&"search") && advertised.contains(&"fetch"), + "both slot aliases are advertised: {advertised:?}" + ); + assert!( + completions[1]["messages"] + .as_array() + .expect("the second round carries the history") + .iter() + .any(|message| message["role"] == "tool"), + "the search result rode back as a tool message" + ); + let searches = gateway + .searches + .lock() + .expect("the capture lock is healthy"); + assert_eq!(searches.len(), 1, "the capability proxied one search"); + assert_eq!(searches[0]["query"], "promptforge"); + assert_eq!( + searches[0]["authorization"], "Bearer test-key", + "the search proxy authenticates with the session's gateway key" + ); + + assert!(sessions.close(&session.id), "the session ends"); + } +} diff --git a/crates/workshop-sessions/src/agents/supervisor/effects.rs b/crates/workshop-sessions/src/agents/supervisor/effects.rs index d67500de..e6722245 100644 --- a/crates/workshop-sessions/src/agents/supervisor/effects.rs +++ b/crates/workshop-sessions/src/agents/supervisor/effects.rs @@ -11,6 +11,7 @@ use workshop_gateway::GatewaySnapshot; use workshop_menu::ChatCatalog; use workshop_protocol::Activity; +use crate::agents::environment::{current_model, session_environment}; use crate::agents::{ AgentSession, AgentSource, SessionHost, SessionObserver, agent_client, delta_stamp, ui_provider, }; @@ -55,6 +56,7 @@ struct RunFactory { observer: Arc, on_delta: Arc, ui: Arc serde_json::Value + Send + Sync>, + host: SessionHost, } impl RunFactory { @@ -73,25 +75,41 @@ impl RunFactory { ui: ui_provider(host.menu(), host.registry()), session, observer, + host: host.clone(), } } /// Builds one run over retained history and frozen bindings. - fn launch(&self, run: RunId, client: ModelClient) -> RunFuture { + fn launch( + &self, + run: RunId, + client: ModelClient, + environment: Arc, + gateway: Arc, + ) -> RunFuture { let AgentSource::Markdown(source) = self.session.source.clone(); - self.launch_markdown(run, source, client) + self.launch_markdown(run, source, client, environment, gateway) } /// Builds one unified-runtime run of a Markdown prompt document. - fn launch_markdown(&self, run: RunId, source: String, client: ModelClient) -> RunFuture { + fn launch_markdown( + &self, + run: RunId, + source: String, + client: ModelClient, + environment: Arc, + gateway: Arc, + ) -> RunFuture { let parts = MarkdownRunParts { session: Arc::clone(&self.session), observer: Arc::clone(&self.observer), ui: Arc::clone(&self.ui), on_delta: Arc::clone(&self.on_delta), + host: self.host.clone(), }; Box::pin(async move { - let result = run_markdown_agent(&source, parts, run, client).await; + let result = + run_markdown_agent(&source, parts, run, client, &environment, &gateway).await; (run, result) }) } @@ -104,25 +122,30 @@ struct MarkdownRunParts { observer: Arc, ui: Arc serde_json::Value + Send + Sync>, on_delta: Arc, + host: SessionHost, } /// 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 no picker and the run config keeps its stock store -/// handle. +/// channel. The run prepares against the session's shared environment - +/// the first-party capabilities the prompt's frontmatter declares - and +/// the context carries the dropdown's current model resolved at launch, +/// so a selection change takes effect on the next run. async fn run_markdown_agent( source: &str, parts: MarkdownRunParts, run: RunId, client: ModelClient, + environment: &Environment, + gateway: &GatewaySnapshot, ) -> Result<(), AgentRunError> { let MarkdownRunParts { session, observer, ui, on_delta, + host, } = parts; let prompt = Prompt::parse(source, &session.id, observer.as_ref()).map_err(|error| { AgentRunError::Failed { @@ -134,15 +157,18 @@ async fn run_markdown_agent( Arc::clone(&session.waits), session.input_frames.clone(), )); - let env = Environment::new(); - let ctx = RunContext::new(session.id.clone()) + let model = current_model(&host, gateway.base_url(), gateway.api_key()).await; + let mut ctx = RunContext::new(session.id.clone()) .observer(observer) .client(client) .cancel(session.arm_cancel(run)) .input_broker(broker) .ui(ui) .on_delta(on_delta); - match env.run(&prompt, "", ctx).await { + if let Some(model) = model { + ctx = ctx.model(model); + } + match environment.run(&prompt, "", ctx).await { RunResult::Ok(_output) => Ok(()), RunResult::Cancelled => Err(AgentRunError::Interrupted), RunResult::Failure(error) => Err(AgentRunError::Failed { @@ -157,6 +183,12 @@ pub(super) struct EffectExecutor { session: Arc, host: SessionHost, factory: RunFactory, + /// The shared model-free environment every run prepares against, + /// rebuilt when the gateway generation changes so a replacement + /// gateway's root and key reach the contributed tools. + environment: Option>, + /// The gateway generation `environment` was built from. + environment_generation: u64, latest_catalog: Option, active_catalog: Option, latest_gateway: Arc, @@ -172,8 +204,13 @@ impl EffectExecutor { initial_catalog: Option, initial_gateway: Arc, ) -> Self { + let environment = + session_environment(initial_gateway.base_url(), initial_gateway.api_key()) + .map(Arc::new); Self { factory: RunFactory::new(Arc::clone(&session), &host), + environment_generation: initial_gateway.generation(), + environment, session, host, latest_catalog: initial_catalog, @@ -268,12 +305,28 @@ impl EffectExecutor { ); return failed_relaunch(relaunch.run); }; + if gateway.generation() != self.environment_generation { + self.environment = + session_environment(gateway.base_url(), gateway.api_key()).map(Arc::new); + self.environment_generation = gateway.generation(); + } + let Some(environment) = self.environment.clone() else { + report_failure( + &self.session, + &self.host, + "the Gateway settings cannot build the promptforge/web capability", + ); + return failed_relaunch(relaunch.run); + }; match relaunch.history { HistoryEffect::Preserve => {} } self.active_catalog = Some(catalog); - self.active_gateway = Some(gateway); - self.active_run = Some(self.factory.launch(relaunch.run, client)); + self.active_gateway = Some(Arc::clone(&gateway)); + self.active_run = Some( + self.factory + .launch(relaunch.run, client, environment, gateway), + ); EffectOutcome::Continue } } diff --git a/crates/workshop-sessions/src/agents/tests.rs b/crates/workshop-sessions/src/agents/tests.rs index 361cc380..b4bf91bd 100644 --- a/crates/workshop-sessions/src/agents/tests.rs +++ b/crates/workshop-sessions/src/agents/tests.rs @@ -1,6 +1,8 @@ +use std::num::NonZeroU32; use std::sync::atomic::AtomicU64; use shared_promptforge_api::events::RuntimeEventKind; +use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; use shared_promptforge_api::observe::{Observation, Observer}; use workshop_protocol::Activity; @@ -237,17 +239,33 @@ fn the_model_client_requires_a_usable_key_and_url() { assert!(agent_client("not a url", "k").is_none()); } +/// The descriptor the chat unit runs bind the declared `chat` role to. +fn test_model() -> ModelDescriptor { + ModelDescriptor::new( + ModelId::gateway("test-model").expect("the test model id is valid"), + "test model", + NonZeroU32::new(8192).expect("8192 is non-zero"), + ThinkingMode::Never, + ) +} + /// Runs the embedded chat prompt on the unified runtime with the given -/// broker configuration, against a client no model call can survive. +/// broker configuration, against a client no model call can survive. The +/// environment carries the first-party capabilities exactly as the +/// session wiring builds them, and the context carries the current model, +/// because the prompt now declares its contract in frontmatter. async fn run_builtin_chat( broker: Option>, ) -> Result { - use promptforge_api::{Environment, Prompt, RunContext, RunResult}; + use promptforge_api::{Prompt, RunContext, RunResult}; 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 env = Environment::new(); - let mut ctx = RunContext::new("chat-unit").observer(observer); + let env = session_environment("http://127.0.0.1:9", "k") + .expect("a well-shaped gateway root builds the session environment"); + let mut ctx = RunContext::new("chat-unit") + .observer(observer) + .model(test_model()); if let Some(broker) = broker { ctx = ctx.input_broker(broker); } @@ -258,6 +276,36 @@ async fn run_builtin_chat( } } +#[test] +fn the_builtin_chat_declares_its_contract_in_frontmatter() { + let prompt = promptforge_api::Prompt::parse( + BUILTIN_CHAT_SOURCE, + "chat-unit", + &shared_promptforge_api::observe::NullObserver::default(), + ) + .expect("the embedded chat prompt parses"); + let frontmatter = prompt.frontmatter(); + let capabilities = frontmatter.capabilities(); + assert_eq!( + capabilities.len(), + 1, + "chat declares exactly one capability" + ); + assert_eq!(capabilities[0].id().to_string(), "promptforge/web"); + assert!( + !capabilities[0].is_optional(), + "the built-in host always installs its own web capability" + ); + let tools = frontmatter.tools(); + assert_eq!(tools.len(), 2, "both web tools get exact slots"); + assert!(tools.get("fetch").is_some(), "the fetch slot is declared"); + assert!(tools.get("search").is_some(), "the search slot is declared"); + assert!( + frontmatter.models().get("chat").is_some(), + "the chat role is declared for the host's current model" + ); +} + #[tokio::test] async fn the_builtin_chat_returns_without_a_broker_beneath_it() { // No broker is the unavailable-fallback policy: user_input() diff --git a/crates/workshop-sessions/src/lib.rs b/crates/workshop-sessions/src/lib.rs index 26fedfe3..55b4aafc 100644 --- a/crates/workshop-sessions/src/lib.rs +++ b/crates/workshop-sessions/src/lib.rs @@ -32,6 +32,6 @@ mod relay; mod session; pub mod state; -pub use agents::{AgentSessions, SessionHost}; +pub use agents::{AgentSessions, SessionHost, session_environment}; pub use input::{SessionInputBroker, WaitError, WaitRegistry, deliver_input_response}; pub use state::{SessionsState, register, routes}; diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index a033edc1..5b654761 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -1026,7 +1026,7 @@ Combine `promptforge-webfetch` and `promptforge-web-search` into the single `pro -### Step 17: Workshop wiring +### Step 17: Workshop wiring [completed] - Component: first-party From 3a3db8cfc7694b832f4a0e85ef48422499abb51f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 01:37:38 -0700 Subject: [PATCH 18/30] Document the frontmatter contract and facade Web The language guide now teaches the prompt's frontmatter contract - capabilities, tool slots, model roles, and typed args declared up front and satisfied by the host at prepare - replacing the old bind-from-Lua model, with migration notes wherever the removed calls were documented. The run chapter gains the prepare pass and the three run outcomes, the globals chapter gains the parsed input view with its repair pattern, and the project principles gain a rule that error and status messages are written for model consumption. The first-party web capability is re-exported through the public api crate so hosts never depend on the internal pack crate. - `crates/promptforge-api/src/capabilities.rs` re-exports the first-party web capability through the facade, so hosts import it from the one public door; the sessions crate drops its direct dependency on the pack crate and builds it through the re-export. - `AGENTS.md` gains a principle: error and status messages are designed assuming model consumption, concise, factual, and self-contained, naming required versus actual. - `guide/src/language/01-frontmatter-and-structure.md` introduces the four contract keys as one declaration the host satisfies before anything runs; `guide/promptforge-language-guide.md` carries the same updates in the single-file rendering. - `guide/src/language/02-the-run.md` documents the prepare pass, the preflight report of unmet requirements and missing capabilities, and the three run outcomes: completed text, distinct cancellation, typed failure. - `guide/src/language/04-lua-globals-and-store.md` documents the parsed input global, the H1 repair pattern for malformed input, and the freeze that fails any write after the preamble. - `guide/src/language/06-models.md` replaces bind-from-Lua with declared roles, the closed keyword vocabulary split into hard checks and soft intent, and the deliberately trivial fill. - `guide/src/language/07-tools.md` teaches capabilities as the installation unit, global tool paths, the binding-versus-advertising split, and the decision-tool recipe, plus a migration note for the removed bind call. - `prompt` reflection global, the open posture, and the prompt-pack capability are each marked designed but not yet built. Design: new facade @ crates/promptforge-api/src/capabilities.rs::Web boundary: pub Deferred: the open posture key stays reserved and unbuilt Deferred: the prompt reflection global stays designed but unbuilt Deferred: the prompt-pack capability stays designed but unbuilt Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- AGENTS.md | 1 + Cargo.lock | 2 +- crates/promptforge-api/Cargo.toml | 1 + crates/promptforge-api/src/capabilities.rs | 4 + crates/promptforge-api/src/lib.rs | 2 +- crates/workshop-sessions/Cargo.toml | 1 - .../src/agents/environment.rs | 4 +- guide/promptforge-language-guide.md | 188 +++++++++++++++--- .../language/01-frontmatter-and-structure.md | 11 + guide/src/language/02-the-run.md | 14 +- .../src/language/04-lua-globals-and-store.md | 26 ++- guide/src/language/05-prose-substitution.md | 7 +- guide/src/language/06-models.md | 41 +++- guide/src/language/07-tools.md | 89 +++++++-- ...2026-09-13-1-capabilities-global-naming.md | 2 +- 15 files changed, 335 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a4985a28..84c28c1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Multi-crate Rust workspace for the PromptForge pipeline runtime, inference gatew 2. Make the smallest improvement to an existing facility which enables the capability. 3. Add a new facility. New machinery must have a material benefit beyond tidiness. - When improving an existing facility, prefer an improvement that serves a problem class beyond the current case over one that solves only the case at hand, when the general shape costs no more. +- Error and status messages are designed assuming model consumption: concise, factual, and self-contained, naming what is missing or unmet with required versus actual, because a message may arrive as tool output that a model reasons about. ## Roles diff --git a/Cargo.lock b/Cargo.lock index daa64ff1..8a736f98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4826,6 +4826,7 @@ dependencies = [ "promptforge-store", "promptforge-tool-picker", "promptforge-vfs", + "promptforge-web", "promptforge-web-search", "rand 0.9.5", "serde", @@ -8631,7 +8632,6 @@ dependencies = [ "axum", "futures-util", "promptforge-api", - "promptforge-web", "rand 0.9.5", "serde", "serde_json", diff --git a/crates/promptforge-api/Cargo.toml b/crates/promptforge-api/Cargo.toml index 79e4522a..557d4044 100644 --- a/crates/promptforge-api/Cargo.toml +++ b/crates/promptforge-api/Cargo.toml @@ -21,6 +21,7 @@ promptforge-parser.workspace = true promptforge-store.workspace = true promptforge-tool-picker.workspace = true promptforge-vfs.workspace = true +promptforge-web.workspace = true promptforge-web-search.workspace = true rand.workspace = true serde.workspace = true diff --git a/crates/promptforge-api/src/capabilities.rs b/crates/promptforge-api/src/capabilities.rs index 4a6e3c10..8db4c5ec 100644 --- a/crates/promptforge-api/src/capabilities.rs +++ b/crates/promptforge-api/src/capabilities.rs @@ -65,6 +65,10 @@ use shared_promptforge_api::capabilities::{Capability, CapabilityId}; #[cfg(test)] mod tests; +// The first-party capability rides the facade so hosts never name the +// internal pack crate (the one-door rule). +pub use promptforge_web::Web; + /// The synthetic third segment keying a capability in the lint catalog. /// /// The picker's catalog speaks three-segment tool ids, so each capability diff --git a/crates/promptforge-api/src/lib.rs b/crates/promptforge-api/src/lib.rs index 05f2a51a..e33b4446 100644 --- a/crates/promptforge-api/src/lib.rs +++ b/crates/promptforge-api/src/lib.rs @@ -90,7 +90,7 @@ pub(crate) mod untrusted; pub(crate) use crate::error::{Error, Result}; pub(crate) use crate::tools::NearDuplicateDiagnostic; -pub use crate::capabilities::{CapabilityRegistry, RegistryError, RegistryErrorKind}; +pub use crate::capabilities::{CapabilityRegistry, RegistryError, RegistryErrorKind, Web}; pub use crate::client::{CompletionError, CompletionErrorKind}; pub use crate::execute::{ Environment, RequirementCheck, Requirements, RunContext, RunError, RunErrorKind, RunLimits, diff --git a/crates/workshop-sessions/Cargo.toml b/crates/workshop-sessions/Cargo.toml index 04b8ac92..129e52bb 100644 --- a/crates/workshop-sessions/Cargo.toml +++ b/crates/workshop-sessions/Cargo.toml @@ -17,7 +17,6 @@ async-trait.workspace = true axum.workspace = true futures-util.workspace = true promptforge-api.workspace = true -promptforge-web.workspace = true shared-promptforge-api.workspace = true rand.workspace = true serde.workspace = true diff --git a/crates/workshop-sessions/src/agents/environment.rs b/crates/workshop-sessions/src/agents/environment.rs index a62f8a21..3d582512 100644 --- a/crates/workshop-sessions/src/agents/environment.rs +++ b/crates/workshop-sessions/src/agents/environment.rs @@ -6,7 +6,7 @@ use std::num::NonZeroU32; use std::sync::Arc; use promptforge_api::client::fetch_model_catalog; -use promptforge_api::{CapabilityRegistry, Environment}; +use promptforge_api::{CapabilityRegistry, Environment, Web}; use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; use super::SessionHost; @@ -32,7 +32,7 @@ const FALLBACK_CONTEXT: NonZeroU32 = match NonZeroU32::new(8192) { #[must_use] pub fn session_environment(base_url: &str, api_key: &str) -> Option { let root = format!("{}/v1", base_url.trim_end_matches('/')); - let web = match promptforge_web::Web::new(&root, api_key) { + let web = match Web::new(&root, api_key) { Ok(web) => web, Err(error) => { tracing::warn!(%error, "agent sessions degraded: the gateway cannot build promptforge/web"); diff --git a/guide/promptforge-language-guide.md b/guide/promptforge-language-guide.md index f117f80d..5a4d7465 100644 --- a/guide/promptforge-language-guide.md +++ b/guide/promptforge-language-guide.md @@ -34,6 +34,17 @@ The `promptforge` key is what makes the file a promptforge prompt at all. A file The parser is strict here. A leading UTF-8 byte-order mark is dropped. Malformed YAML fails the parse and preserves the underlying cause. Unknown or misspelled keys are rejected at parse time rather than silently ignored, so a typo such as `desciption:` fails loudly instead of being skipped. +## The contract keys + +Four optional frontmatter keys declare what the prompt needs from its host. Together they form the prompt's contract, and the host satisfies it before anything runs (see [The Run](02-the-run.md)): + +- `capabilities:` lists the capabilities the prompt activates, by global id. A capability id has exactly two segments, `namespace/pack`. A bare id declares a required capability; the map form, `{ ref: namespace/pack, optional: true }`, declares one the run skips when absent, and may carry prompt-side `config` data. See [Tools](07-tools.md). +- `tools:` declares the run's tool slots, keyed by a prompt-local alias. A bare string is an exact global tool path (`namespace/pack/name`, exactly three segments); the map form, `{ want: "prose description" }`, is a fuzzy slot filled at prepare. See [Tools](07-tools.md). +- `models:` declares the run's model roles, keyed by a prompt-local label, each with a keyword set, an optional `min_context` token floor, and a description. See [Models](06-models.md). +- `args:` declares the run's typed input fields, each with a `type` (`string`, `boolean`, `integer`, or `number`), an `optional` flag, an optional `default`, and a description. A prompt with no `args:` key gets the default declaration: one optional string field named `prose`. See [Lua Globals and the Store](04-lua-globals-and-store.md). + +Aliases, labels, and arg names share one grammar: `[A-Za-z][A-Za-z0-9_-]{0,63}`. These names are prompt-local; the model only ever sees them, never a global path. The parser's strictness covers the contract keys too: a malformed capability id, an unknown model keyword, or an arg default whose type differs from its declaration fails the parse with the position named. + ## Declaring input and output files Two optional frontmatter keys declare the store files your prompt works with. The `input:` key names a file the prompt expects at start. The `output:` key names a file it leaves at finish. Each declaration pairs a store-internal `path` with a human-readable `description` that documents the file's role. @@ -58,11 +69,17 @@ One last structural rule. A prompt allows at most one `lua shared` fence, and on # The Run -You can now write a well-formed prompt file, so the next question is what happens when it runs. This chapter walks a run from beginning to end: the live pass over the title, the ordered walk through the sections, how results appear, and how a run finishes. Once you can picture a run, every other feature of the language has a place to attach. +You can now write a well-formed prompt file, so the next question is what happens when it runs. This chapter walks a run from beginning to end: the prepare pass that satisfies the prompt's contract, the live pass over the title, the ordered walk through the sections, how results appear, and how a run finishes. Once you can picture a run, every other feature of the language has a place to attach. + +## Prepare: satisfaction before the walk + +A prompt never binds its own models and tools; it declares them, and the host satisfies the declaration before the run begins. When you run a prompt, the host first prepares the run against its environment: it activates each declared capability, assembles the catalog of tools those capabilities contribute, and fills every declared slot - each model role bound to a concrete model, each tool slot bound to a concrete tool. Every fill is journaled, so the host can show you exactly what a fuzzy `want` resolved to. + +Prepare then checks the declaration against what the environment could satisfy and reports what still needs human attention: model requirements the filled model does not meet (a `min_context` above the model's context window, or a hard keyword its descriptor contradicts), required capabilities that are missing or failed to activate, and declared capability pairs that cannot activate together. When the report is clean the run begins. When it is not, the run fails before the walk with a notice naming each gap, required versus actual. ## The preamble -When a run starts, the H1 section's Lua and prose blocks run first, in a live pass with full host access. This pass is the prompt's preamble. It is where the prompt declares which models and tools the run may use: `models.bind` and `models.default` declare model aliases resolved from capability descriptions, and `tools.bind` declares a tool alias the same way. Once the preamble finishes, those bindings are structurally frozen for the rest of the run. +When a run starts, the H1 section's Lua and prose blocks run first, in a live pass with full host access. This pass is the prompt's preamble. Binding is already done - prepare filled every declared role and slot - so the preamble arranges the run's own affairs: `models.default` parks a declared role as the prompt-wide default, `tools.always` advertises a bound tool in every section, and the H1 pass is the one place `argv` is writable, so a prompt that repairs malformed input does it here (see [Lua Globals and the Store](04-lua-globals-and-store.md)). A prompt with only an H1 title and no sections still runs. And a scalar `return` from the live H1 pass short-circuits the whole run: the returned value becomes the run's result, and no section ever fires. @@ -74,7 +91,7 @@ After the preamble, the top-level sections run in file order. The first H2 secti Each section runs in its own isolated, sandboxed Lua state. Only the `string`, `table`, and `math` standard libraries plus safe base functions are available. The state is created at section entry and torn down at exit, so one section's Lua cannot leak into the next. -A section that talks to the model needs a model. `models.use` selects a bound alias for one section, and the prompt-wide default covers sections that select nothing; a model-facing call with neither fails with a model-required error. Tools follow the same pattern: `tools.always` or `tools.add` scope a bound tool to the model under its local alias. +A section that talks to the model needs a model. `models.use` selects a declared role by its label for one section, and the prompt-wide default covers sections that select nothing; a model-facing call with neither fails with a model-required error. Tools follow the same pattern: `tools.always` or `tools.add` advertise a bound tool to the model under its local alias. ## Lua blocks and prose blocks @@ -86,6 +103,8 @@ Prose is data, not an implicit model turn. The prose written between a heading o A scalar `return` from a section's Lua block ends the run early with that value. When the first section returns `"first"`, a later section's own `return "unreached"` is never reached. A run in which no section returns finishes with the generic completion "done". +The host sees one of three outcomes. A completed run yields its final text. A cancelled run reports cancellation distinctly, so an interrupted run is never mistaken for a failed one. A failure carries a typed error whose kind classifies the fault - parse, binding, completion, tool, and so on - with a message written to be read and, when the failure has a source position, the prompt name and line to navigate to. Domain outcomes, including the prompt declining to answer, are ordinary result text, not failures. + ## What carries between sections Sections are isolated in Lua, but three things roll forward through the walk. The `var` table is a per-run clipboard: it is seeded into each section's Lua state on entry and read back before teardown, so the next section sees the updates. The run-scoped `store` persists bulk state as virtual files addressed by logical string paths, shared across every section of the run. And everything else moves explicitly: `call(heading, input)` hands a subroutine its input and returns its result, so the author chooses what crosses a section boundary. @@ -173,16 +192,32 @@ Remember that the H1 pass runs first with full host access. The tool and model b # Lua Globals and the Store -Every section runs sandboxed Lua, but it does not run empty-handed. This chapter teaches the globals the runtime seeds into each section, `args`, `sys`, `var`, `prose`, and `log`, plus the run-scoped `store` where a prompt keeps its bulk state. These are your everyday tools, so we take them one at a time. +Every section runs sandboxed Lua, but it does not run empty-handed. This chapter teaches the globals the runtime seeds into each section, `args` and `argv`, `sys`, `var`, `prose`, and `log`, plus the run-scoped `store` where a prompt keeps its bulk state. These are your everyday tools, so we take them one at a time. -## args: the run's input +## args and argv: the run's input -Every section's Lua block can read the run's argument string through the `args` global: +Every section's Lua block can read the run's exact argument string through the `args` global: ````lua log('the run was started with: ' .. args) ```` +The `argv` global is the parsed form of that string, shaped by the prompt's `args:` declaration. A prompt with no `args:` key has the default declaration - one optional string field named `prose` - and the interface wraps the argument string into it, so `argv.prose` reads the input text on every channel, the empty string included. A prompt with a structured `args:` declaration receives its argument string as JSON: the call argument `{"query": "papers", "limit": 5}` arrives as a table with `argv.query` and `argv.limit`. When the string does not parse as JSON, or parses as `null`, `argv` is nil, so `if argv then` is the idiomatic malformed-input check. + +Optional means absent. A call that omits an optional field leaves `argv.field` nil; absent is not the empty string, and a present empty string is a real value the caller chose to send. + +### The H1 repair pattern + +The `argv` global is writable in the H1 pass and frozen everywhere else. A prompt that tolerates malformed input reads the raw `args`, computes the repair, and assigns it: + +````lua +if not argv then + argv = { query = args } +end +```` + +The executor reads the value back when the H1 pass completes, and every later section sees the repaired value frozen: reads work, absent fields read nil, and any assignment - `argv = ...` or a field write at any depth - fails with an error naming the freeze. + ## sys: runtime metadata Every section receives a `sys` JSON value carrying `when`, `now`, `id`, `section_name`, `execution`, and `section_count`. @@ -238,6 +273,10 @@ Three more operations help with larger files. The call `store.read_numbered(path When store content goes back to the model, wrap it first. The `untrusted(text)` global wraps store content in a guard envelope before it is re-injected, so the model treats it as data rather than instructions. +## Designed, not yet built: the prompt global + +A `prompt` reflection global is designed but not yet built. It will expose the prompt's own declaration to section Lua - the declared model roles, tool slots, and args - so a prompt can adapt its behavior to how it was satisfied. Today the declaration is visible to the host that runs the prompt, not to the prompt's own code. + --- # Prose Substitution @@ -248,7 +287,8 @@ Prose blocks are not static text. When a Lua block reads its `prose` value, `{{ Each placeholder names a namespace and, for most of them, a key: -- `{{ args }}` inserts the run's input string. +- `{{ args }}` inserts the run's input string, exactly as passed. +- `{{ argv }}` inserts the parsed form of the input as compact JSON, and `{{ argv.key }}` indexes into it. - `{{ item }}` inserts the current member when the section runs as an arm of a fanout. - `{{ var.key }}` inserts a field of the `var` clipboard. - `{{ sys.key }}` inserts runtime metadata. @@ -256,6 +296,8 @@ Each placeholder names a namespace and, for most of them, a key: So `hi {{ args }}!` with the run argument `Acme Corp` reads as `hi Acme Corp!`. +The `args` and `argv` namespaces are two views of one input. `{{ args }}` is always the exact string the run was started with. `{{ argv }}` is its parsed shape under the prompt's `args:` declaration (see [Lua Globals and the Store](04-lua-globals-and-store.md)): `{{ argv }}` renders the whole value as compact JSON, and a dotted path such as `{{ argv.query }}` indexes into it. + ## Dotted paths and structured values Dotted paths index into nested values. With `var.row = { a = 1 }`, the placeholder `{{ var.row.a }}` renders `1`. A placeholder that resolves to a whole table or array renders as compact JSON, so `{{ var.row }}` renders `{"a":1}`. @@ -274,7 +316,7 @@ Substitution is also lazy. It runs on the first read of `prose`, not at block en Substitution failures are ordinary Lua errors raised at the read site, with specific messages, so a block can catch them with `pcall`. The failures cover an unknown namespace or global, a missing key, a null value, a bare `{{ var }}` or `{{ sys }}`, dotted indexing into a string, an unclosed `{{`, empty path segments, and non-JSON globals. -One placeholder has a precondition. Using `{{ item }}` outside a fanout arm is an error because no collection member exists. +Two placeholders have preconditions. Using `{{ item }}` outside a fanout arm is an error because no collection member exists. And using `{{ argv }}` or any `{{ argv.key }}` path when the input did not parse - a nil `argv` - is an error, never a silent empty string. ## How item renders @@ -284,9 +326,9 @@ Inside a fanout arm, `{{ item }}` renders the current collection member by type. # Models -A prompt does not name a model directly. It describes the capability it needs, and the runtime resolves that description against the catalog. This chapter teaches the three calls that declare and select models, `models.bind`, `models.default`, and `models.use`, plus the two operations that run model rounds from Lua, `models.infer` and `models.loop`. Capability-based binding is what keeps a prompt portable across catalogs, so it is worth learning as a habit from the start. +A prompt does not name a model directly. It declares the roles it needs in the frontmatter, and the host binds every role to a concrete model before the run begins. This chapter teaches the declaration, the two calls that select among bound roles, `models.default` and `models.use`, plus the two operations that run model rounds from Lua, `models.infer` and `models.loop`. Declared roles are what keep a prompt portable across catalogs, so it is worth learning as a habit from the start. -## Binding a model +## Declaring a role Declare a model role in the frontmatter with the `models` key: @@ -294,10 +336,15 @@ Declare a model role in the frontmatter with the `models` key: models: analyst: keywords: [no-thinking] + min_context: 40000 description: careful analysis ```` -Each key is a local label. A role carries a keyword set from a closed vocabulary, an optional `min_context` token floor, and a description. Prepare fills every declared role from the host's current model and checks the hard keywords and the context minimum against the filled model. +Each key is a prompt-local label. A role carries a keyword set, an optional `min_context` token floor, and a description. + +The keyword vocabulary is closed, and split in two. The hard keywords, `thinking` and `no-thinking`, are checked at prepare against the filled model's descriptor, as is the context minimum: a role requiring `min_context: 200000` filled with a 32k model, or requiring `thinking` filled with a model that never thinks, is reported as an unmet requirement naming the role, required versus actual. The soft keywords - `frontier`, `fast`, `small`, `creative`, and `chat` - document author intent for the day a smarter fill can shop for them. An unknown keyword fails the parse; adding a keyword is a language change. + +Today's fill is deliberately trivial: every declared role binds to the host's current model (in the Workshop, the dropdown's selection). The declaration is written for the full contract - roles, requirements, checks - so the same prompt runs unchanged when a smarter fill arrives; only the binding decisions change. ## The default model @@ -307,15 +354,15 @@ The call `models.default` designates the prompt-wide default, parking a declared models.default("writer") ```` -The label names a role declared in the frontmatter `models` key, and `models.default` may be called at most once per prompt. +The label names a role declared in the frontmatter `models` key, and an unknown label is a hard error, because every label must be declared. Naming the same label again is a no-op, so a shared library replayed into every section may name the default; naming a different label fails, because the prompt-wide default cannot change mid-run. ## Selecting a model for a section -Inside a section, `models.use('analyst')` selects a bound alias for that section. The selection is read when a model round starts, so a later `models.use` call in the same section replaces it and steers the next round. A section that runs a model round needs a model from `models.use` or from the prompt-wide default; with neither, the call fails with a model-required error. +Inside a section, `models.use('analyst')` selects a bound role by its label for that section. The selection is read when a model round starts, so a later `models.use` call in the same section replaces it and steers the next round. A section that runs a model round needs a model from `models.use` or from the prompt-wide default; with neither, the call fails with a model-required error. ## Inspecting a binding -The call `models.get(alias)` returns an inspectable handle with `name`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. +Every bound role is also a bare global holding an inspectable handle, and `models.get(label)` returns the same handle, with `name`, `label`, `capabilities`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. ## Direct inference @@ -358,27 +405,71 @@ The field `sys.model` is not readable from Lua before the section's first model A run that needs an environment variable that is not set fails with an error naming the missing variable. A variable that is set but holds a non-Unicode value is a distinct failure. +## Migrating from models.bind + +Earlier versions bound models from Lua, resolving a prose description against the catalog at run time. The declaration moved to the frontmatter, and binding moved to prepare. Before: + +````lua +models.bind('analyst', 'a careful model that does not think') +models.default('analyst') +```` + +After: + +````yaml +models: + analyst: + keywords: [no-thinking] + description: careful analysis +```` + +````lua +models.default('analyst') +```` + +The `models.bind` call is removed. What was its prose description now documents the role, the hard requirements ride `keywords` and `min_context`, and `models.default` and `models.use` name declared labels only. + --- # Tools -Models reach the outside world through tools, and a prompt controls exactly which tools the model can see. This chapter teaches the declaration and scoping calls, `tools.bind`, `tools.always`, and `tools.add`, plus local tools written in Lua, direct dispatch with `tools.call`, and the failure modes you will meet. Tool scoping is the prompt's main safety surface, so we build it up one call at a time. +Models reach the outside world through tools, and a prompt controls exactly which tools the model can see. Tools arrive in capabilities, the installation unit, and a prompt declares the capabilities it activates and the tool slots it binds in the frontmatter; the host fills every slot before the run begins. This chapter teaches the declaration, the advertising calls `tools.always` and `tools.add`, local tools written in Lua, direct dispatch with `tools.call`, and the failure modes you will meet. Tool scoping is the prompt's main safety surface, so we build it up one idea at a time. + +## Capabilities and global names + +A capability is the activation unit: code that runs at run setup and contributes tools. Every capability has a global id of exactly two segments, `namespace/pack`, where the namespace is a reverse-DNS name such as `io.github.corp` or the reserved first-party prefix `promptforge`. Every tool has a global path of exactly three segments, `namespace/pack/name`, and a tool's first two segments always name the capability that contributed it: `promptforge/web/fetch` comes from the `promptforge/web` capability, no exceptions. + +Declare the capabilities a prompt activates with the `capabilities` key: + +````yaml +capabilities: + - promptforge/web + - ref: io.github.corp/vault + optional: true +```` + +A bare id declares a required capability: when it is absent from the host's registry or fails to activate, the run cannot start, and the preflight report names it. The map form with `optional: true` declares a capability the run skips with a log line when absent, so one prompt runs with or without an enhancement; the optional `config` key carries prompt-side data to the capability. User-specific configuration such as credentials is host-supplied and never named in the prompt. -## Declaring a tool +## Declaring a tool slot -Declare a tool slot in the frontmatter with the `tools` key; a `want` description is filled by the picker at prepare, an exact global path by identity: +The `tools` key declares the run's tool slots, keyed by a prompt-local alias: ````yaml tools: search: want: search the web + fetch: promptforge/web/fetch ```` -The call `tools.bind` alone advertises nothing to the model; it only declares the alias. Binding resolves the description against the live catalog, and the failures are typed and specific: no match for the description, an ambiguous match listing the candidate identities, a duplicate alias, the same tool selected twice, or a picked tool absent from the live catalog. A capability description is resolved at most once per run, so repeated binds of the same description return the identical cached outcome, including identical failures. +A bare string is an exact global path, filled by identity against the assembled catalog. Since the path's first two segments name its capability, a slot whose capability is not active cannot fill, and the preflight report says so. The map form is a fuzzy slot: the `want` prose is matched against the catalog at prepare by the picker, a local sentence-embedding model that maps English descriptions to tools, and every fill is journaled so you can see what the fuzz resolved to. A fuzzy slot with `optional: true` skips with a log line when nothing fills it. + +## Binding versus advertising + +Binding and advertising are separate facts. Binding is decided entirely at prepare: everything a binding decision could depend on - the frontmatter, the active capabilities, the assembled catalog - is known by then, and the journaled result is the run's bindings, alias to tool. What remains for run time is advertising: the prompt's Lua decides per section which already-bound aliases the model gets to see. The model only ever sees the alias, never the global path. -## Scoping a tool to the model +## Advertising a tool to the model -Two calls scope a declared tool to the model under its local alias. The call `tools.always('search')` advertises the tool in every section. The call `tools.add('search')` advertises it in the current section only. To add several declared aliases at once, pass an array: +Two calls advertise a bound tool under its local alias. The call `tools.always('search')` advertises the tool in every section, conventionally from the H1 preamble. The call `tools.add('search')` advertises it in the current section only. To advertise several bound aliases at once, pass an array: ````lua tools.add({"search", "fetch"}) @@ -386,15 +477,15 @@ tools.add({"search", "fetch"}) The array form takes no per-element overrides. -You can replace the description the model sees. The call `tools.add(alias, override)` takes an override, and `tools.bind` and `tools.always` accept the same override as a trailing parameter. Precedence is the `tools.add` override over the `tools.bind` or `tools.always` override over the tool's catalog text. +You can replace the description the model sees. The call `tools.add(alias, override)` takes an override, and `tools.always` accepts the same override as a trailing parameter. Precedence is the `tools.add` override over the `tools.always` override over the tool's catalog text. -The calls `tools.bind` and `tools.always` return a frozen Tool object with `name`, `description`, `parameters`, `wire_name`, and `untrusted` fields, and `tools.add` accepts Tool objects as well as alias strings. +Each bound slot is also a bare global holding a frozen Tool object with `name`, `description`, `parameters`, `wire_name`, and `untrusted` fields, and `tools.add` accepts Tool objects as well as alias strings. Because `tools.always` records a prompt-wide fact in state every section shares, naming the same alias again is a no-op, so a shared library replayed into every section may name it. ## The tool loop The tool loop lives inside `models.loop`. When the model answers a loop request with structured tool calls, the runtime dispatches each call to a tool in the section's scope, appends the correlated results to the message list, and asks again, until the model replies with terminal text. The scope is read at call time, so a `tools.add` earlier in the same Lua block applies to the `models.loop` call that follows it. -Calling `tools.add` with an alias that no `tools.bind` declared fails the run loudly. A model that calls a tool outside the section's advertised scope fails with an error listing the in-scope aliases, and the error notes when the alias was declared by `tools.bind` but not added to this section's scope. +Calling `tools.add` with an alias that no frontmatter slot declared fails the run loudly. A model that calls a tool outside the section's advertised scope fails with an error listing the in-scope aliases, and the error notes when the alias was declared but not added to this section's scope. ## Local tools @@ -406,13 +497,29 @@ tools['add_local']('grab', 'Grab a value', { value = 'string' }, function(args) end) ```` -The handler runs as a Lua function in the section's own state. The parameter table is rendered to the model as a JSON schema with required properties. The handler's returned string goes back to the model verbatim and trusted. The handler can use `store` and section-global variables, but it cannot call `jump`, and a handler error fails the run with the handler's message. A local tool alias cannot collide with a `tools.bind` alias or with another local alias, and every tool schema advertised to the model is validated before it is sent. +The handler runs as a Lua function in the section's own state. The parameter table is rendered to the model as a JSON schema with required properties; each value is a bare type string or a `{type, description}` pair. The handler's returned string goes back to the model verbatim and trusted. The handler can use `store` and section-global variables, but it cannot call `jump`, and a handler error fails the run with the handler's message. A local tool alias cannot collide with a declared slot alias or with another local alias, and every tool schema advertised to the model is validated before it is sent. + +## The decision-tool recipe + +When prose guidance should steer the run's shape - which sections to walk, which bound tools to advertise - do not ask the model for prose and string-parse the answer. Interpret the guidance into flags with a local decision tool in the H1 preamble: + +````lua +tools['add_local']('decide', 'Record the verdict: one of use_mcp, no_mcp, or unspecified', { choice = 'string' }, function(args) + var.verdict = args.choice + return 'recorded' +end) +local msgs = messages.new() +msgs:user('Given these instructions, decide whether the private sources are needed: ' .. args) +models.loop(msgs) +```` + +The model's tool call lands in the Lua handler, which records the verdict where the walk can read it. Three rules keep the recipe honest. The choice set must include an explicit "unspecified" verdict, so a genuine abstention has a name. The no-call exit is handled in code: when the loop finishes without a call, `var.verdict` is simply unset, and the prompt treats that as abstention. And for weaker models that struggle with parameterized calls, the fallback is three no-arg tools, one per verdict, instead of one tool with a parameter. Decision-tool results are journaled like any tool call, so a replay consumes the recorded verdict rather than re-rolling it. ## Direct dispatch and call counts -The call `tools.call(alias, args)` invokes any tool bound in the document directly from a Lua block, even one not scoped into the section, without widening the set advertised to the model. A `tools.call` with an alias that has no binding fails with an error listing every bound alias. A Tool object works in place of the alias, so `tools.call(tool, args)` dispatches a held object directly. +The call `tools.call(alias, args)` invokes any tool bound in the document directly from a Lua block, even one not advertised in the section, without widening the set the model can see. A `tools.call` with an alias that has no binding fails with an error listing every bound alias. A Tool object works in place of the alias, so `tools.call(tool, args)` dispatches a held object directly. -The counter `tools.calls[alias]` reads how many times the model has called a tool in the section. Reading it with an alias that was never bound is a hard error naming the bad key and listing the seeded aliases. The counter records a call even when the tool errors. +The counter `tools.calls[alias]` reads how many times the model has called a tool in the section. Reading it with an alias that was never declared is a hard error naming the bad key and listing the declared aliases. The counter records a call even when the tool errors. ## Trusted and untrusted output @@ -424,6 +531,35 @@ Two semantic near-duplicate tools in one model-visible scope fail validation, wi An empty final reply from the model fails the loop unless a tool call preceded it and the finish reason is `stop`. A `length` finish reason returns the partial text and reports truncation. And a tool handler failure aborts the tool loop and fails the run with the tool's own error, preserving the underlying cause in the error chain. +## Migrating from tools.bind + +Earlier versions bound tools from Lua, resolving a prose description against the catalog at run time. The declaration moved to the frontmatter, and binding moved to prepare. Before: + +````lua +tools.bind('search', 'search the web') +tools.always('search') +```` + +After: + +````yaml +capabilities: + - promptforge/web +tools: + search: + want: search the web +```` + +````lua +tools.always('search') +```` + +The `tools.bind` call is removed. What was its prose description is now the fuzzy slot's `want`, filled by the picker at prepare with the fill journaled; an exact path fills by identity. The advertising calls, `tools.always` and `tools.add`, are unchanged. + +## Designed, not yet built + +Two extensions are designed but not yet built. The open posture, `tools: { open: true }`, lets a prompt accept whatever capabilities the host arms the run with instead of declaring its own; the `open` key is reserved today, so writing it fails the parse with a message saying so. And the prompt-pack capability contributes a directory of prompts as tools, one tool per prompt: invoking the tool runs the prompt as a sub-run, and the sub-run's result text becomes the tool output. Both arrive without structural change to what this chapter teaches. + --- # Control Flow diff --git a/guide/src/language/01-frontmatter-and-structure.md b/guide/src/language/01-frontmatter-and-structure.md index 1b82bb73..5515efe0 100644 --- a/guide/src/language/01-frontmatter-and-structure.md +++ b/guide/src/language/01-frontmatter-and-structure.md @@ -30,6 +30,17 @@ The `promptforge` key is what makes the file a promptforge prompt at all. A file The parser is strict here. A leading UTF-8 byte-order mark is dropped. Malformed YAML fails the parse and preserves the underlying cause. Unknown or misspelled keys are rejected at parse time rather than silently ignored, so a typo such as `desciption:` fails loudly instead of being skipped. +## The contract keys + +Four optional frontmatter keys declare what the prompt needs from its host. Together they form the prompt's contract, and the host satisfies it before anything runs (see [The Run](02-the-run.md)): + +- `capabilities:` lists the capabilities the prompt activates, by global id. A capability id has exactly two segments, `namespace/pack`. A bare id declares a required capability; the map form, `{ ref: namespace/pack, optional: true }`, declares one the run skips when absent, and may carry prompt-side `config` data. See [Tools](07-tools.md). +- `tools:` declares the run's tool slots, keyed by a prompt-local alias. A bare string is an exact global tool path (`namespace/pack/name`, exactly three segments); the map form, `{ want: "prose description" }`, is a fuzzy slot filled at prepare. See [Tools](07-tools.md). +- `models:` declares the run's model roles, keyed by a prompt-local label, each with a keyword set, an optional `min_context` token floor, and a description. See [Models](06-models.md). +- `args:` declares the run's typed input fields, each with a `type` (`string`, `boolean`, `integer`, or `number`), an `optional` flag, an optional `default`, and a description. A prompt with no `args:` key gets the default declaration: one optional string field named `prose`. See [Lua Globals and the Store](04-lua-globals-and-store.md). + +Aliases, labels, and arg names share one grammar: `[A-Za-z][A-Za-z0-9_-]{0,63}`. These names are prompt-local; the model only ever sees them, never a global path. The parser's strictness covers the contract keys too: a malformed capability id, an unknown model keyword, or an arg default whose type differs from its declaration fails the parse with the position named. + ## Declaring input and output files Two optional frontmatter keys declare the store files your prompt works with. The `input:` key names a file the prompt expects at start. The `output:` key names a file it leaves at finish. Each declaration pairs a store-internal `path` with a human-readable `description` that documents the file's role. diff --git a/guide/src/language/02-the-run.md b/guide/src/language/02-the-run.md index e56243ce..0322d75f 100644 --- a/guide/src/language/02-the-run.md +++ b/guide/src/language/02-the-run.md @@ -1,10 +1,16 @@ # The Run -You can now write a well-formed prompt file, so the next question is what happens when it runs. This chapter walks a run from beginning to end: the live pass over the title, the ordered walk through the sections, how results appear, and how a run finishes. Once you can picture a run, every other feature of the language has a place to attach. +You can now write a well-formed prompt file, so the next question is what happens when it runs. This chapter walks a run from beginning to end: the prepare pass that satisfies the prompt's contract, the live pass over the title, the ordered walk through the sections, how results appear, and how a run finishes. Once you can picture a run, every other feature of the language has a place to attach. + +## Prepare: satisfaction before the walk + +A prompt never binds its own models and tools; it declares them, and the host satisfies the declaration before the run begins. When you run a prompt, the host first prepares the run against its environment: it activates each declared capability, assembles the catalog of tools those capabilities contribute, and fills every declared slot - each model role bound to a concrete model, each tool slot bound to a concrete tool. Every fill is journaled, so the host can show you exactly what a fuzzy `want` resolved to. + +Prepare then checks the declaration against what the environment could satisfy and reports what still needs human attention: model requirements the filled model does not meet (a `min_context` above the model's context window, or a hard keyword its descriptor contradicts), required capabilities that are missing or failed to activate, and declared capability pairs that cannot activate together. When the report is clean the run begins. When it is not, the run fails before the walk with a notice naming each gap, required versus actual. ## The preamble -When a run starts, the H1 section's Lua and prose blocks run first, in a live pass with full host access. This pass is the prompt's preamble. It is where the prompt declares which models and tools the run may use: `models.bind` and `models.default` declare model aliases resolved from capability descriptions, and `tools.bind` declares a tool alias the same way. Once the preamble finishes, those bindings are structurally frozen for the rest of the run. +When a run starts, the H1 section's Lua and prose blocks run first, in a live pass with full host access. This pass is the prompt's preamble. Binding is already done - prepare filled every declared role and slot - so the preamble arranges the run's own affairs: `models.default` parks a declared role as the prompt-wide default, `tools.always` advertises a bound tool in every section, and the H1 pass is the one place `argv` is writable, so a prompt that repairs malformed input does it here (see [Lua Globals and the Store](04-lua-globals-and-store.md)). A prompt with only an H1 title and no sections still runs. And a scalar `return` from the live H1 pass short-circuits the whole run: the returned value becomes the run's result, and no section ever fires. @@ -16,7 +22,7 @@ After the preamble, the top-level sections run in file order. The first H2 secti Each section runs in its own isolated, sandboxed Lua state. Only the `string`, `table`, and `math` standard libraries plus safe base functions are available. The state is created at section entry and torn down at exit, so one section's Lua cannot leak into the next. -A section that talks to the model needs a model. `models.use` selects a bound alias for one section, and the prompt-wide default covers sections that select nothing; a model-facing call with neither fails with a model-required error. Tools follow the same pattern: `tools.always` or `tools.add` scope a bound tool to the model under its local alias. +A section that talks to the model needs a model. `models.use` selects a declared role by its label for one section, and the prompt-wide default covers sections that select nothing; a model-facing call with neither fails with a model-required error. Tools follow the same pattern: `tools.always` or `tools.add` advertise a bound tool to the model under its local alias. ## Lua blocks and prose blocks @@ -28,6 +34,8 @@ Prose is data, not an implicit model turn. The prose written between a heading o A scalar `return` from a section's Lua block ends the run early with that value. When the first section returns `"first"`, a later section's own `return "unreached"` is never reached. A run in which no section returns finishes with the generic completion "done". +The host sees one of three outcomes. A completed run yields its final text. A cancelled run reports cancellation distinctly, so an interrupted run is never mistaken for a failed one. A failure carries a typed error whose kind classifies the fault - parse, binding, completion, tool, and so on - with a message written to be read and, when the failure has a source position, the prompt name and line to navigate to. Domain outcomes, including the prompt declining to answer, are ordinary result text, not failures. + ## What carries between sections Sections are isolated in Lua, but three things roll forward through the walk. The `var` table is a per-run clipboard: it is seeded into each section's Lua state on entry and read back before teardown, so the next section sees the updates. The run-scoped `store` persists bulk state as virtual files addressed by logical string paths, shared across every section of the run. And everything else moves explicitly: `call(heading, input)` hands a subroutine its input and returns its result, so the author chooses what crosses a section boundary. diff --git a/guide/src/language/04-lua-globals-and-store.md b/guide/src/language/04-lua-globals-and-store.md index 39e9ab6a..054ad953 100644 --- a/guide/src/language/04-lua-globals-and-store.md +++ b/guide/src/language/04-lua-globals-and-store.md @@ -1,15 +1,31 @@ # Lua Globals and the Store -Every section runs sandboxed Lua, but it does not run empty-handed. This chapter teaches the globals the runtime seeds into each section, `args`, `sys`, `var`, `prose`, and `log`, plus the run-scoped `store` where a prompt keeps its bulk state. These are your everyday tools, so we take them one at a time. +Every section runs sandboxed Lua, but it does not run empty-handed. This chapter teaches the globals the runtime seeds into each section, `args` and `argv`, `sys`, `var`, `prose`, and `log`, plus the run-scoped `store` where a prompt keeps its bulk state. These are your everyday tools, so we take them one at a time. -## args: the run's input +## args and argv: the run's input -Every section's Lua block can read the run's argument string through the `args` global: +Every section's Lua block can read the run's exact argument string through the `args` global: ````lua log('the run was started with: ' .. args) ```` +The `argv` global is the parsed form of that string, shaped by the prompt's `args:` declaration. A prompt with no `args:` key has the default declaration - one optional string field named `prose` - and the interface wraps the argument string into it, so `argv.prose` reads the input text on every channel, the empty string included. A prompt with a structured `args:` declaration receives its argument string as JSON: the call argument `{"query": "papers", "limit": 5}` arrives as a table with `argv.query` and `argv.limit`. When the string does not parse as JSON, or parses as `null`, `argv` is nil, so `if argv then` is the idiomatic malformed-input check. + +Optional means absent. A call that omits an optional field leaves `argv.field` nil; absent is not the empty string, and a present empty string is a real value the caller chose to send. + +### The H1 repair pattern + +The `argv` global is writable in the H1 pass and frozen everywhere else. A prompt that tolerates malformed input reads the raw `args`, computes the repair, and assigns it: + +````lua +if not argv then + argv = { query = args } +end +```` + +The executor reads the value back when the H1 pass completes, and every later section sees the repaired value frozen: reads work, absent fields read nil, and any assignment - `argv = ...` or a field write at any depth - fails with an error naming the freeze. + ## sys: runtime metadata Every section receives a `sys` JSON value carrying `when`, `now`, `id`, `section_name`, `execution`, and `section_count`. @@ -65,3 +81,7 @@ Three more operations help with larger files. The call `store.read_numbered(path When store content goes back to the model, wrap it first. The `untrusted(text)` global wraps store content in a guard envelope before it is re-injected, so the model treats it as data rather than instructions. +## Designed, not yet built: the prompt global + +A `prompt` reflection global is designed but not yet built. It will expose the prompt's own declaration to section Lua - the declared model roles, tool slots, and args - so a prompt can adapt its behavior to how it was satisfied. Today the declaration is visible to the host that runs the prompt, not to the prompt's own code. + diff --git a/guide/src/language/05-prose-substitution.md b/guide/src/language/05-prose-substitution.md index 50168434..b64ac45b 100644 --- a/guide/src/language/05-prose-substitution.md +++ b/guide/src/language/05-prose-substitution.md @@ -6,7 +6,8 @@ Prose blocks are not static text. When a Lua block reads its `prose` value, `{{ Each placeholder names a namespace and, for most of them, a key: -- `{{ args }}` inserts the run's input string. +- `{{ args }}` inserts the run's input string, exactly as passed. +- `{{ argv }}` inserts the parsed form of the input as compact JSON, and `{{ argv.key }}` indexes into it. - `{{ item }}` inserts the current member when the section runs as an arm of a fanout. - `{{ var.key }}` inserts a field of the `var` clipboard. - `{{ sys.key }}` inserts runtime metadata. @@ -14,6 +15,8 @@ Each placeholder names a namespace and, for most of them, a key: So `hi {{ args }}!` with the run argument `Acme Corp` reads as `hi Acme Corp!`. +The `args` and `argv` namespaces are two views of one input. `{{ args }}` is always the exact string the run was started with. `{{ argv }}` is its parsed shape under the prompt's `args:` declaration (see [Lua Globals and the Store](04-lua-globals-and-store.md)): `{{ argv }}` renders the whole value as compact JSON, and a dotted path such as `{{ argv.query }}` indexes into it. + ## Dotted paths and structured values Dotted paths index into nested values. With `var.row = { a = 1 }`, the placeholder `{{ var.row.a }}` renders `1`. A placeholder that resolves to a whole table or array renders as compact JSON, so `{{ var.row }}` renders `{"a":1}`. @@ -32,7 +35,7 @@ Substitution is also lazy. It runs on the first read of `prose`, not at block en Substitution failures are ordinary Lua errors raised at the read site, with specific messages, so a block can catch them with `pcall`. The failures cover an unknown namespace or global, a missing key, a null value, a bare `{{ var }}` or `{{ sys }}`, dotted indexing into a string, an unclosed `{{`, empty path segments, and non-JSON globals. -One placeholder has a precondition. Using `{{ item }}` outside a fanout arm is an error because no collection member exists. +Two placeholders have preconditions. Using `{{ item }}` outside a fanout arm is an error because no collection member exists. And using `{{ argv }}` or any `{{ argv.key }}` path when the input did not parse - a nil `argv` - is an error, never a silent empty string. ## How item renders diff --git a/guide/src/language/06-models.md b/guide/src/language/06-models.md index 0adb11c3..5b52a45a 100644 --- a/guide/src/language/06-models.md +++ b/guide/src/language/06-models.md @@ -1,8 +1,8 @@ # Models -A prompt does not name a model directly. It describes the capability it needs, and the runtime resolves that description against the catalog. This chapter teaches the three calls that declare and select models, `models.bind`, `models.default`, and `models.use`, plus the two operations that run model rounds from Lua, `models.infer` and `models.loop`. Capability-based binding is what keeps a prompt portable across catalogs, so it is worth learning as a habit from the start. +A prompt does not name a model directly. It declares the roles it needs in the frontmatter, and the host binds every role to a concrete model before the run begins. This chapter teaches the declaration, the two calls that select among bound roles, `models.default` and `models.use`, plus the two operations that run model rounds from Lua, `models.infer` and `models.loop`. Declared roles are what keep a prompt portable across catalogs, so it is worth learning as a habit from the start. -## Binding a model +## Declaring a role Declare a model role in the frontmatter with the `models` key: @@ -10,10 +10,15 @@ Declare a model role in the frontmatter with the `models` key: models: analyst: keywords: [no-thinking] + min_context: 40000 description: careful analysis ```` -Each key is a local label. A role carries a keyword set from a closed vocabulary, an optional `min_context` token floor, and a description. Prepare fills every declared role from the host's current model and checks the hard keywords and the context minimum against the filled model. +Each key is a prompt-local label. A role carries a keyword set, an optional `min_context` token floor, and a description. + +The keyword vocabulary is closed, and split in two. The hard keywords, `thinking` and `no-thinking`, are checked at prepare against the filled model's descriptor, as is the context minimum: a role requiring `min_context: 200000` filled with a 32k model, or requiring `thinking` filled with a model that never thinks, is reported as an unmet requirement naming the role, required versus actual. The soft keywords - `frontier`, `fast`, `small`, `creative`, and `chat` - document author intent for the day a smarter fill can shop for them. An unknown keyword fails the parse; adding a keyword is a language change. + +Today's fill is deliberately trivial: every declared role binds to the host's current model (in the Workshop, the dropdown's selection). The declaration is written for the full contract - roles, requirements, checks - so the same prompt runs unchanged when a smarter fill arrives; only the binding decisions change. ## The default model @@ -23,15 +28,15 @@ The call `models.default` designates the prompt-wide default, parking a declared models.default("writer") ```` -The label names a role declared in the frontmatter `models` key, and `models.default` may be called at most once per prompt. +The label names a role declared in the frontmatter `models` key, and an unknown label is a hard error, because every label must be declared. Naming the same label again is a no-op, so a shared library replayed into every section may name the default; naming a different label fails, because the prompt-wide default cannot change mid-run. ## Selecting a model for a section -Inside a section, `models.use('analyst')` selects a bound alias for that section. The selection is read when a model round starts, so a later `models.use` call in the same section replaces it and steers the next round. A section that runs a model round needs a model from `models.use` or from the prompt-wide default; with neither, the call fails with a model-required error. +Inside a section, `models.use('analyst')` selects a bound role by its label for that section. The selection is read when a model round starts, so a later `models.use` call in the same section replaces it and steers the next round. A section that runs a model round needs a model from `models.use` or from the prompt-wide default; with neither, the call fails with a model-required error. ## Inspecting a binding -The call `models.get(alias)` returns an inspectable handle with `name`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. +Every bound role is also a bare global holding an inspectable handle, and `models.get(label)` returns the same handle, with `name`, `label`, `capabilities`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. ## Direct inference @@ -73,3 +78,27 @@ The field `sys.model` is not readable from Lua before the section's first model ## Environment variables A run that needs an environment variable that is not set fails with an error naming the missing variable. A variable that is set but holds a non-Unicode value is a distinct failure. + +## Migrating from models.bind + +Earlier versions bound models from Lua, resolving a prose description against the catalog at run time. The declaration moved to the frontmatter, and binding moved to prepare. Before: + +````lua +models.bind('analyst', 'a careful model that does not think') +models.default('analyst') +```` + +After: + +````yaml +models: + analyst: + keywords: [no-thinking] + description: careful analysis +```` + +````lua +models.default('analyst') +```` + +The `models.bind` call is removed. What was its prose description now documents the role, the hard requirements ride `keywords` and `min_context`, and `models.default` and `models.use` name declared labels only. diff --git a/guide/src/language/07-tools.md b/guide/src/language/07-tools.md index 6d6823cc..2416ab2e 100644 --- a/guide/src/language/07-tools.md +++ b/guide/src/language/07-tools.md @@ -1,22 +1,42 @@ # Tools -Models reach the outside world through tools, and a prompt controls exactly which tools the model can see. This chapter teaches the declaration and scoping calls, `tools.bind`, `tools.always`, and `tools.add`, plus local tools written in Lua, direct dispatch with `tools.call`, and the failure modes you will meet. Tool scoping is the prompt's main safety surface, so we build it up one call at a time. +Models reach the outside world through tools, and a prompt controls exactly which tools the model can see. Tools arrive in capabilities, the installation unit, and a prompt declares the capabilities it activates and the tool slots it binds in the frontmatter; the host fills every slot before the run begins. This chapter teaches the declaration, the advertising calls `tools.always` and `tools.add`, local tools written in Lua, direct dispatch with `tools.call`, and the failure modes you will meet. Tool scoping is the prompt's main safety surface, so we build it up one idea at a time. -## Declaring a tool +## Capabilities and global names -Declare a tool slot in the frontmatter with the `tools` key; a `want` description is filled by the picker at prepare, an exact global path by identity: +A capability is the activation unit: code that runs at run setup and contributes tools. Every capability has a global id of exactly two segments, `namespace/pack`, where the namespace is a reverse-DNS name such as `io.github.corp` or the reserved first-party prefix `promptforge`. Every tool has a global path of exactly three segments, `namespace/pack/name`, and a tool's first two segments always name the capability that contributed it: `promptforge/web/fetch` comes from the `promptforge/web` capability, no exceptions. + +Declare the capabilities a prompt activates with the `capabilities` key: + +````yaml +capabilities: + - promptforge/web + - ref: io.github.corp/vault + optional: true +```` + +A bare id declares a required capability: when it is absent from the host's registry or fails to activate, the run cannot start, and the preflight report names it. The map form with `optional: true` declares a capability the run skips with a log line when absent, so one prompt runs with or without an enhancement; the optional `config` key carries prompt-side data to the capability. User-specific configuration such as credentials is host-supplied and never named in the prompt. + +## Declaring a tool slot + +The `tools` key declares the run's tool slots, keyed by a prompt-local alias: ````yaml tools: search: want: search the web + fetch: promptforge/web/fetch ```` -The call `tools.bind` alone advertises nothing to the model; it only declares the alias. Binding resolves the description against the live catalog, and the failures are typed and specific: no match for the description, an ambiguous match listing the candidate identities, a duplicate alias, the same tool selected twice, or a picked tool absent from the live catalog. A capability description is resolved at most once per run, so repeated binds of the same description return the identical cached outcome, including identical failures. +A bare string is an exact global path, filled by identity against the assembled catalog. Since the path's first two segments name its capability, a slot whose capability is not active cannot fill, and the preflight report says so. The map form is a fuzzy slot: the `want` prose is matched against the catalog at prepare by the picker, a local sentence-embedding model that maps English descriptions to tools, and every fill is journaled so you can see what the fuzz resolved to. A fuzzy slot with `optional: true` skips with a log line when nothing fills it. + +## Binding versus advertising + +Binding and advertising are separate facts. Binding is decided entirely at prepare: everything a binding decision could depend on - the frontmatter, the active capabilities, the assembled catalog - is known by then, and the journaled result is the run's bindings, alias to tool. What remains for run time is advertising: the prompt's Lua decides per section which already-bound aliases the model gets to see. The model only ever sees the alias, never the global path. -## Scoping a tool to the model +## Advertising a tool to the model -Two calls scope a declared tool to the model under its local alias. The call `tools.always('search')` advertises the tool in every section. The call `tools.add('search')` advertises it in the current section only. To add several declared aliases at once, pass an array: +Two calls advertise a bound tool under its local alias. The call `tools.always('search')` advertises the tool in every section, conventionally from the H1 preamble. The call `tools.add('search')` advertises it in the current section only. To advertise several bound aliases at once, pass an array: ````lua tools.add({"search", "fetch"}) @@ -24,15 +44,15 @@ tools.add({"search", "fetch"}) The array form takes no per-element overrides. -You can replace the description the model sees. The call `tools.add(alias, override)` takes an override, and `tools.bind` and `tools.always` accept the same override as a trailing parameter. Precedence is the `tools.add` override over the `tools.bind` or `tools.always` override over the tool's catalog text. +You can replace the description the model sees. The call `tools.add(alias, override)` takes an override, and `tools.always` accepts the same override as a trailing parameter. Precedence is the `tools.add` override over the `tools.always` override over the tool's catalog text. -The calls `tools.bind` and `tools.always` return a frozen Tool object with `name`, `description`, `parameters`, `wire_name`, and `untrusted` fields, and `tools.add` accepts Tool objects as well as alias strings. +Each bound slot is also a bare global holding a frozen Tool object with `name`, `description`, `parameters`, `wire_name`, and `untrusted` fields, and `tools.add` accepts Tool objects as well as alias strings. Because `tools.always` records a prompt-wide fact in state every section shares, naming the same alias again is a no-op, so a shared library replayed into every section may name it. ## The tool loop The tool loop lives inside `models.loop`. When the model answers a loop request with structured tool calls, the runtime dispatches each call to a tool in the section's scope, appends the correlated results to the message list, and asks again, until the model replies with terminal text. The scope is read at call time, so a `tools.add` earlier in the same Lua block applies to the `models.loop` call that follows it. -Calling `tools.add` with an alias that no `tools.bind` declared fails the run loudly. A model that calls a tool outside the section's advertised scope fails with an error listing the in-scope aliases, and the error notes when the alias was declared by `tools.bind` but not added to this section's scope. +Calling `tools.add` with an alias that no frontmatter slot declared fails the run loudly. A model that calls a tool outside the section's advertised scope fails with an error listing the in-scope aliases, and the error notes when the alias was declared but not added to this section's scope. ## Local tools @@ -44,13 +64,29 @@ tools['add_local']('grab', 'Grab a value', { value = 'string' }, function(args) end) ```` -The handler runs as a Lua function in the section's own state. The parameter table is rendered to the model as a JSON schema with required properties. The handler's returned string goes back to the model verbatim and trusted. The handler can use `store` and section-global variables, but it cannot call `jump`, and a handler error fails the run with the handler's message. A local tool alias cannot collide with a `tools.bind` alias or with another local alias, and every tool schema advertised to the model is validated before it is sent. +The handler runs as a Lua function in the section's own state. The parameter table is rendered to the model as a JSON schema with required properties; each value is a bare type string or a `{type, description}` pair. The handler's returned string goes back to the model verbatim and trusted. The handler can use `store` and section-global variables, but it cannot call `jump`, and a handler error fails the run with the handler's message. A local tool alias cannot collide with a declared slot alias or with another local alias, and every tool schema advertised to the model is validated before it is sent. + +## The decision-tool recipe + +When prose guidance should steer the run's shape - which sections to walk, which bound tools to advertise - do not ask the model for prose and string-parse the answer. Interpret the guidance into flags with a local decision tool in the H1 preamble: + +````lua +tools['add_local']('decide', 'Record the verdict: one of use_mcp, no_mcp, or unspecified', { choice = 'string' }, function(args) + var.verdict = args.choice + return 'recorded' +end) +local msgs = messages.new() +msgs:user('Given these instructions, decide whether the private sources are needed: ' .. args) +models.loop(msgs) +```` + +The model's tool call lands in the Lua handler, which records the verdict where the walk can read it. Three rules keep the recipe honest. The choice set must include an explicit "unspecified" verdict, so a genuine abstention has a name. The no-call exit is handled in code: when the loop finishes without a call, `var.verdict` is simply unset, and the prompt treats that as abstention. And for weaker models that struggle with parameterized calls, the fallback is three no-arg tools, one per verdict, instead of one tool with a parameter. Decision-tool results are journaled like any tool call, so a replay consumes the recorded verdict rather than re-rolling it. ## Direct dispatch and call counts -The call `tools.call(alias, args)` invokes any tool bound in the document directly from a Lua block, even one not scoped into the section, without widening the set advertised to the model. A `tools.call` with an alias that has no binding fails with an error listing every bound alias. A Tool object works in place of the alias, so `tools.call(tool, args)` dispatches a held object directly. +The call `tools.call(alias, args)` invokes any tool bound in the document directly from a Lua block, even one not advertised in the section, without widening the set the model can see. A `tools.call` with an alias that has no binding fails with an error listing every bound alias. A Tool object works in place of the alias, so `tools.call(tool, args)` dispatches a held object directly. -The counter `tools.calls[alias]` reads how many times the model has called a tool in the section. Reading it with an alias that was never bound is a hard error naming the bad key and listing the seeded aliases. The counter records a call even when the tool errors. +The counter `tools.calls[alias]` reads how many times the model has called a tool in the section. Reading it with an alias that was never declared is a hard error naming the bad key and listing the declared aliases. The counter records a call even when the tool errors. ## Trusted and untrusted output @@ -61,3 +97,32 @@ Output from a tool that marks its result untrusted is wrapped in a preface and n Two semantic near-duplicate tools in one model-visible scope fail validation, with an error naming both aliases, both identities, and the similarity score. If you genuinely need both, isolate them in separate sections with per-section `tools.add`. An empty final reply from the model fails the loop unless a tool call preceded it and the finish reason is `stop`. A `length` finish reason returns the partial text and reports truncation. And a tool handler failure aborts the tool loop and fails the run with the tool's own error, preserving the underlying cause in the error chain. + +## Migrating from tools.bind + +Earlier versions bound tools from Lua, resolving a prose description against the catalog at run time. The declaration moved to the frontmatter, and binding moved to prepare. Before: + +````lua +tools.bind('search', 'search the web') +tools.always('search') +```` + +After: + +````yaml +capabilities: + - promptforge/web +tools: + search: + want: search the web +```` + +````lua +tools.always('search') +```` + +The `tools.bind` call is removed. What was its prose description is now the fuzzy slot's `want`, filled by the picker at prepare with the fill journaled; an exact path fills by identity. The advertising calls, `tools.always` and `tools.add`, are unchanged. + +## Designed, not yet built + +Two extensions are designed but not yet built. The open posture, `tools: { open: true }`, lets a prompt accept whatever capabilities the host arms the run with instead of declaring its own; the `open` key is reserved today, so writing it fails the parse with a message saying so. And the prompt-pack capability contributes a directory of prompts as tools, one tool per prompt: invoking the tool runs the prompt as a sub-run, and the sub-run's result text becomes the tool output. Both arrive without structural change to what this chapter teaches. diff --git a/vibe/2026-09-13-1-capabilities-global-naming.md b/vibe/2026-09-13-1-capabilities-global-naming.md index 5b654761..1bc089d5 100644 --- a/vibe/2026-09-13-1-capabilities-global-naming.md +++ b/vibe/2026-09-13-1-capabilities-global-naming.md @@ -1036,7 +1036,7 @@ Combine `promptforge-webfetch` and `promptforge-web-search` into the single `pro -### Step 18: Guide and AGENTS.md +### Step 18: Guide and AGENTS.md [completed] - Component: docs From 89b4c83b85bffe42ae3c1e6716f9da8ac23bb090 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 01:59:09 -0700 Subject: [PATCH 19/30] Close plan: capabilities and global naming Plan: vibe/2026-09-13-1-capabilities-global-naming.md --- vibe/ACTIVE | 1 - 1 file changed, 1 deletion(-) delete mode 100644 vibe/ACTIVE diff --git a/vibe/ACTIVE b/vibe/ACTIVE deleted file mode 100644 index ef6180bc..00000000 --- a/vibe/ACTIVE +++ /dev/null @@ -1 +0,0 @@ -vibe/2026-09-13-1-capabilities-global-naming.md \ No newline at end of file From 232204cf3b7f9f55b87c9dcf825b076c78846b00 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 02:16:08 -0700 Subject: [PATCH 20/30] Surface tool dispatch failures to the Workshop SPA A tool dispatch failure aborts the model loop the same way a failed model round does, and the built-in chat's pcall swallows both, so the session observer is the only place that can tell the SPA. `SessionObserver::observe` now treats `Observation::ToolCallFailed` like `Observation::ModelTurnFailed`: it settles the turn, sends the error frame, and pushes a terminal failure status. - `SessionObserver::observe` derives the failure label from the observation's `Display` instead of the literal `"Model turn failed"`, so the frame names the boundary that failed. - `a_failed_tool_call_pushes_a_terminal_failure_status` pins the status severity, the non-thinking activity that releases the busy LED, and the error frame text `Tool call failed in agent `chat``. - The observation carries no payload, so the frame names the section but not the tool's own error message; that message is still only in the swallowed pcall result. --- .../workshop-sessions/src/agents/session.rs | 21 ++++++---- crates/workshop-sessions/src/agents/tests.rs | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/workshop-sessions/src/agents/session.rs b/crates/workshop-sessions/src/agents/session.rs index 4c205a99..2b863635 100644 --- a/crates/workshop-sessions/src/agents/session.rs +++ b/crates/workshop-sessions/src/agents/session.rs @@ -194,21 +194,26 @@ pub(crate) struct SessionObserver { impl Observer for SessionObserver { fn observe(&self, execution: &str, section: &str, event: Observation) { - // A failed model round is operator-visible: the program survives - // it (the built-in chat pcalls models.chat and returns to - // waiting), so the run never fails and only the session can tell - // the SPA. The observation carries no payload; the frame names - // the boundary that failed. - if matches!(event, Observation::ModelTurnFailed) { + // A failed model round or tool dispatch is operator-visible: the + // program survives it (the built-in chat pcalls models.loop and + // returns to waiting), so the run never fails and only the session + // can tell the SPA. A tool dispatch failure aborts the loop just as + // a failed round does, so both are terminal for the turn. The + // observation carries no payload; the frame names the boundary + // that failed. + if matches!( + event, + Observation::ModelTurnFailed | Observation::ToolCallFailed + ) { self.lifecycle.settle_current_turn(); let message = format!("{event} in agent `{section}`"); let _ = self.errors.send(message.clone()); - // The failed round never reaches on_assistant_reply, so this + // The failed turn never reaches on_assistant_reply, so this // terminal status is the only frame that releases the // turn-dispatch Thinking push; without it the status bar's // sustained amber LED never returns to idle. self.push - .push_failure("Model turn failed", message, Activity::General); + .push_failure(event.to_string(), message, Activity::General); } self.log.observe(execution, section, event); } diff --git a/crates/workshop-sessions/src/agents/tests.rs b/crates/workshop-sessions/src/agents/tests.rs index b4bf91bd..84f8d29d 100644 --- a/crates/workshop-sessions/src/agents/tests.rs +++ b/crates/workshop-sessions/src/agents/tests.rs @@ -226,6 +226,47 @@ async fn a_failed_model_turn_pushes_a_terminal_failure_status() { ); } +#[tokio::test] +async fn a_failed_tool_call_pushes_a_terminal_failure_status() { + // A tool dispatch failure aborts the model loop the same way a failed + // model round does, and the built-in chat's pcall swallows both; without + // this frame the operator sees a tool call that never returns and a + // status bar stuck busy. + let status = workshop_status::StatusBus::new(); + let mut status_rx = status.subscribe(); + let catalog = CatalogBus::new(); + let menu = MenuBus::new(catalog.clone(), None); + let (push, _guards) = wired_push(&status, &catalog, &menu); + let (errors, mut errors_rx) = broadcast::channel(ERROR_CAPACITY); + let (supervisor_events, _events) = mpsc::unbounded_channel(); + let (cancellations, _cancellation_events) = mpsc::channel(lifecycle::CANCELLATION_CAPACITY); + let observer = SessionObserver { + log: Arc::new(WorkshopObserver::new(None).expect("a memory log")), + rounds: Arc::new(AtomicU64::new(0)), + push, + backoff: ReconnectBackoff::new(), + errors, + lifecycle: Arc::new(RunLifecycle::new(supervisor_events, cancellations)), + }; + + observer.observe("run", "chat", Observation::ToolCallFailed); + + let update = status_rx + .recv() + .await + .expect("the failed dispatch pushes a terminal status"); + assert_eq!(update.severity, workshop_protocol::Severity::Error); + assert_eq!( + update.activity, + Activity::General, + "a non-thinking activity releases the status bar's sustained amber LED" + ); + assert_eq!( + errors_rx.recv().await.expect("the error frame is sent"), + "Tool call failed in agent `chat`" + ); +} + #[test] fn the_model_client_requires_a_usable_key_and_url() { assert!( From 090fc74714afe34da13a6bcf9a21f1fbaca015d2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 02:26:33 -0700 Subject: [PATCH 21/30] Skip non-inference entries in the model catalog fetch The gateway lists its speech-to-text models beside the inference models in `GET /v1/models`, and those entries carry no `context` or `thinking`. `fetch_model_catalog` required both fields on every entry, so one transcription model failed the whole decode and every host on such a gateway fell back to a minimal descriptor whose context window refused real conversations. The inference fields are now optional at the wire and an entry without a context window is skipped. - `ModelsListEntry` makes `description`, `context`, and `thinking` optional with serde defaults; the loop in `fetch_model_catalog` skips an entry whose `context` is `None` before validating its id. - An entry that declares a `context` but no `thinking` is still `MalformedResponse`, as is a zero-token window; skipping applies only to entries with no window at all. - `fetch_model_catalog_skips_entries_without_a_context_window` pins the mixed list (one chat model, two transcription entries) yielding one descriptor with the declared window; `fetch_model_catalog_still_rejects_a_zero_context_window` pins the retained zero-window rejection. --- .../src/model/transport.rs | 98 ++++++++++++++++++- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/promptforge-model-client/src/model/transport.rs b/crates/promptforge-model-client/src/model/transport.rs index a0a8055d..d98d4a29 100644 --- a/crates/promptforge-model-client/src/model/transport.rs +++ b/crates/promptforge-model-client/src/model/transport.rs @@ -8,12 +8,21 @@ use super::{CompletionError, ModelCatalog, ModelDescriptor, ModelId, ThinkingMod use crate::Error; /// Wire shape of one entry from gateway `GET /v1/models`. +/// +/// The list mixes inference models with the gateway's speech-to-text models, +/// which carry only `id`, `object`, and `kind` because they answer no +/// completion request. The inference fields are therefore optional at the +/// wire, and an entry without a context window is skipped rather than +/// failing the whole catalog. #[derive(Debug, Deserialize)] struct ModelsListEntry { id: String, + #[serde(default)] description: String, - context: u32, - thinking: ThinkingMode, + #[serde(default)] + context: Option, + #[serde(default)] + thinking: Option, } /// Wire shape of gateway `GET /v1/models`. @@ -188,22 +197,34 @@ pub async fn fetch_model_catalog( })?; let mut descriptors = Vec::with_capacity(list.data.len()); for entry in list.data { + // An entry with no context window is not an inference model (the + // gateway lists its transcription models here too); it is not a + // descriptor and must not fail the catalog. + let Some(context) = entry.context else { + continue; + }; let id = ModelId::gateway(entry.id).map_err(|error| { CompletionError::from(Error::MalformedResponse(format!( "model catalog entry has an invalid id: {error}" ))) })?; - let context = NonZeroU32::new(entry.context).ok_or_else(|| { + let context = NonZeroU32::new(context).ok_or_else(|| { CompletionError::from(Error::MalformedResponse(format!( "model {} declares a zero-token context window", id.name() ))) })?; + let thinking = entry.thinking.ok_or_else(|| { + CompletionError::from(Error::MalformedResponse(format!( + "model {} declares a context window but no thinking mode", + id.name() + ))) + })?; descriptors.push(ModelDescriptor::new( id, entry.description, context, - entry.thinking, + thinking, )); } ModelCatalog::new(descriptors).map_err(|error| { @@ -304,6 +325,75 @@ mod tests { ); } + #[tokio::test] + async fn fetch_model_catalog_skips_entries_without_a_context_window() { + use axum::Router; + use axum::routing::get; + + // A gateway with speech-to-text lists its transcription models beside + // the inference models, and those entries carry no `context` or + // `thinking` (they answer no completion request). The fetch must keep + // the inference descriptors instead of rejecting the whole list, + // otherwise every host on such a gateway binds under a fallback + // descriptor and the context precheck refuses real conversations. + async fn models() -> axum::Json { + axum::Json(serde_json::json!({ + "object": "list", + "data": [ + { "id": "chat-model", "object": "model", "kind": "chat", + "description": "a chat model", "context": 1_000_000, "thinking": "never" }, + { "id": "whisper-base-en", "object": "model", "kind": "transcription" }, + { "id": "whisper-small-en", "object": "model", "kind": "transcription" } + ] + })) + } + let app = Router::new().route("/models", get(models)); + let addr = spawn_models(app).await; + + let catalog = fetch_model_catalog(&format!("http://{addr}"), "tok") + .await + .expect("transcription entries must not fail the inference catalog"); + assert_eq!( + catalog.models().len(), + 1, + "only the inference model is a descriptor" + ); + let chat = catalog + .get(&ModelId::gateway("chat-model").expect("valid id")) + .expect("the inference model survives the filter"); + assert_eq!( + chat.context(), + NonZeroU32::new(1_000_000).expect("non-zero") + ); + assert_eq!(chat.thinking(), ThinkingMode::Never); + } + + #[tokio::test] + async fn fetch_model_catalog_still_rejects_a_zero_context_window() { + use axum::Router; + use axum::routing::get; + + // Skipping applies only to entries with no context field at all; an + // inference entry that declares a zero window is still malformed. + async fn models() -> axum::Json { + axum::Json(serde_json::json!({ + "object": "list", + "data": [ + { "id": "broken", "object": "model", "kind": "chat", + "description": "d", "context": 0, "thinking": "never" } + ] + })) + } + let app = Router::new().route("/models", get(models)); + let addr = spawn_models(app).await; + + let err = fetch_model_catalog(&format!("http://{addr}"), "tok") + .await + .expect_err("a zero context window is malformed"); + assert_eq!(err.kind(), CompletionErrorKind::MalformedResponse); + assert!(err.to_string().contains("zero-token"), "got {err}"); + } + #[tokio::test] async fn fetch_model_catalog_preserves_a_body_read_failure_source() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; From eeca0a1664f62d981d1845e83d871dcb6c18aaa8 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 02:39:03 -0700 Subject: [PATCH 22/30] Declare the chat role's context minimum in chat.md The built-in chat's web tools ride in the model's context, and a launch that bound an undersized descriptor used to run until the first conversation outgrew the window and failed mid-turn. The `chat` role now declares `min_context: 32768`, so prepare refuses such a binding before the first model call with a notice naming the role, the minimum, and the actual window. - `chat.md` sets `min_context` and a description on the `chat` role; nothing in the Lua changes. - `an_undersized_model_is_refused_before_the_first_turn` pins the refusal: an 8192-token descriptor yields `RunErrorKind::RequirementsUnmet` and the notice names `chat`, `32768`, and `8192`. - Every mock gateway the workshop tests launch chat against now serves a typed `/v1/models` catalog whose entries clear the minimum (`gate_models`, `with_typed_catalog`); those mocks previously served no `context`, so every gate had been running on the fallback descriptor. - The `test_model` fixture and the chat gate's restart descriptor move from 8192 to 200000 tokens for the same reason. --- crates/workshop-server/tests/it/agents.rs | 27 +++++++++++ .../tests/it/agents/replacement.rs | 24 +++++----- crates/workshop-server/tests/it/chat_gate.rs | 36 ++++++++++---- .../tests/it/chat_gate/recovery.rs | 39 ++++++++------- crates/workshop-sessions/agents/chat.md | 4 +- .../src/agents/environment.rs | 2 +- crates/workshop-sessions/src/agents/tests.rs | 47 +++++++++++++++++-- 7 files changed, 135 insertions(+), 44 deletions(-) diff --git a/crates/workshop-server/tests/it/agents.rs b/crates/workshop-server/tests/it/agents.rs index ea6e5938..7b9e29ef 100644 --- a/crates/workshop-server/tests/it/agents.rs +++ b/crates/workshop-server/tests/it/agents.rs @@ -111,6 +111,33 @@ fn hanging_completions(started: &Notify) -> Response { .into_response() } +/// Adds the typed `/v1/models` catalog a launch resolves the menu selection +/// through. Every id these tests select carries a window that clears the +/// built-in chat's declared minimum, so a mock without this route would +/// bind the fallback descriptor and the role's minimum would refuse the run. +fn with_typed_catalog(router: Router) -> Router { + router.route( + "/v1/models", + axum::routing::get(|| async { + let entry = |id: &str| { + json!({ + "id": id, "object": "model", "kind": "chat", "description": id, + "context": 200_000, "thinking": "never", + }) + }; + axum::Json(json!({ + "object": "list", + "data": [ + entry("model-a"), + entry("model-b"), + entry("model-c"), + entry("test-model"), + ], + })) + }), + ) +} + /// Records one completion body for endpoint and binding assertions. fn record_request(requests: &Mutex>, body: &str) { requests diff --git a/crates/workshop-server/tests/it/agents/replacement.rs b/crates/workshop-server/tests/it/agents/replacement.rs index 61ba7a6c..3c184520 100644 --- a/crates/workshop-server/tests/it/agents/replacement.rs +++ b/crates/workshop-server/tests/it/agents/replacement.rs @@ -11,7 +11,7 @@ async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { let request_started = Arc::clone(&started); let original_requests = Arc::new(Mutex::new(Vec::new())); let captured_original = Arc::clone(&original_requests); - let original = spawn_gateway(Router::new().route( + let original = spawn_gateway(with_typed_catalog(Router::new().route( "/v1/chat/completions", post(move |body: String| { let request_started = Arc::clone(&request_started); @@ -21,7 +21,7 @@ async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { hanging_completions(&request_started) } }), - )) + ))) .await; let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; state @@ -72,7 +72,7 @@ async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { .expect("the replacement model becomes selected"); let replacement_requests = Arc::new(Mutex::new(Vec::new())); let captured_replacement = Arc::clone(&replacement_requests); - let replacement = spawn_gateway(Router::new().route( + let replacement = spawn_gateway(with_typed_catalog(Router::new().route( "/v1/chat/completions", post(move |body: String| { let captured_replacement = Arc::clone(&captured_replacement); @@ -81,7 +81,7 @@ async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { echo_completions(body).await } }), - )) + ))) .await; replace_gateway(&state, &replacement, 1_757_000_000); @@ -103,7 +103,7 @@ async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { async fn retained_catalog_generation_replays_on_the_replacement_gateway() { let started = Arc::new(Notify::new()); let request_started = Arc::clone(&started); - let original = spawn_gateway(Router::new().route( + let original = spawn_gateway(with_typed_catalog(Router::new().route( "/v1/chat/completions", post(move |body: String| { let request_started = Arc::clone(&request_started); @@ -116,7 +116,7 @@ async fn retained_catalog_generation_replays_on_the_replacement_gateway() { hanging_completions(&request_started) } }), - )) + ))) .await; let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; state @@ -157,7 +157,7 @@ async fn retained_catalog_generation_replays_on_the_replacement_gateway() { .publish(vec![json!({ "id": "model-a", "object": "model" })]); let replacement_requests = Arc::new(Mutex::new(Vec::new())); let captured_replacement = Arc::clone(&replacement_requests); - let replacement = spawn_gateway(Router::new().route( + let replacement = spawn_gateway(with_typed_catalog(Router::new().route( "/v1/chat/completions", post(move |body: String| { let captured_replacement = Arc::clone(&captured_replacement); @@ -166,7 +166,7 @@ async fn retained_catalog_generation_replays_on_the_replacement_gateway() { echo_completions(body).await } }), - )) + ))) .await; replace_gateway(&state, &replacement, 1_757_000_001); @@ -184,13 +184,13 @@ async fn retained_catalog_generation_replays_on_the_replacement_gateway() { async fn unavailable_catalog_waits_without_relaunching_stale_bindings() { let started = Arc::new(Notify::new()); let request_started = Arc::clone(&started); - let original = spawn_gateway(Router::new().route( + let original = spawn_gateway(with_typed_catalog(Router::new().route( "/v1/chat/completions", post(move || { let request_started = Arc::clone(&request_started); async move { hanging_completions(&request_started) } }), - )) + ))) .await; let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; state @@ -236,7 +236,7 @@ async fn unavailable_catalog_waits_without_relaunching_stale_bindings() { let replacement_request_started = Arc::clone(&replacement_started); let replacement_requests = Arc::new(Mutex::new(Vec::new())); let captured_replacement = Arc::clone(&replacement_requests); - let replacement = spawn_gateway(Router::new().route( + let replacement = spawn_gateway(with_typed_catalog(Router::new().route( "/v1/chat/completions", post(move |body: String| { let replacement_request_started = Arc::clone(&replacement_request_started); @@ -247,7 +247,7 @@ async fn unavailable_catalog_waits_without_relaunching_stale_bindings() { echo_completions(body).await } }), - )) + ))) .await; replace_gateway(&state, &replacement, 1_757_000_002); diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index dc073eb0..7dee3aac 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -159,6 +159,30 @@ struct GateServer { dir: tempfile::TempDir, } +/// The typed catalog a mock gateway serves from `/v1/models`: the launch +/// resolves the menu selection through it, so every id a gate may select +/// carries a window that clears chat's declared minimum, and a gate tests +/// the host wiring rather than a refused binding. `model-b` stays first: +/// the profile-switch gates rely on the menu auto-selecting it from this +/// list. +async fn gate_models() -> axum::Json { + let entry = |id: &str| { + json!({ + "id": id, "object": "model", "kind": "chat", "description": id, + "context": 200_000, "thinking": "never", + }) + }; + axum::Json(json!({ + "object": "list", + "data": [ + entry("model-b"), + entry("model-a"), + entry("test-model"), + entry("claude-opus-4-6"), + ], + })) +} + /// Spawns the gate server with `models` in the retained catalog and the /// first of them selected in the menu. async fn spawn_chat_server(models: &[&str]) -> GateServer { @@ -189,15 +213,7 @@ async fn spawn_chat_server_with_selection(models: &[&str], selected: Option<&str "/admin/status", get(|| async { axum::Json(json!({"profile": "beta"})) }), ) - .route( - "/v1/models", - get(|| async { - axum::Json(json!({ - "object": "list", - "data": [{"id": "model-b", "object": "model"}], - })) - }), - ), + .route("/v1/models", get(gate_models)), ) .await; let dir = tempfile::TempDir::new().expect("tempdir"); @@ -353,7 +369,7 @@ fn spawn_restored_chat( let model = ModelDescriptor::new( ModelId::gateway("test-model").expect("the test model id is valid"), "test model", - std::num::NonZeroU32::new(8192).expect("8192 is non-zero"), + std::num::NonZeroU32::new(200_000).expect("200000 is non-zero"), ThinkingMode::Never, ); let ctx = RunContext::new(session.to_owned()) diff --git a/crates/workshop-server/tests/it/chat_gate/recovery.rs b/crates/workshop-server/tests/it/chat_gate/recovery.rs index d6f417b6..dc33720f 100644 --- a/crates/workshop-server/tests/it/chat_gate/recovery.rs +++ b/crates/workshop-server/tests/it/chat_gate/recovery.rs @@ -7,22 +7,29 @@ async fn a_live_chat_session_restarts_on_the_replacement_port_and_key() { let replacement_captured = CapturedRequests::default(); let captured = Arc::clone(&replacement_captured); - let replacement = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |headers: axum::http::HeaderMap, body: String| { - let captured = Arc::clone(&captured); - async move { - if headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - != Some("Bearer replacement-key") - { - return StatusCode::UNAUTHORIZED.into_response(); - } - gate_completions(&captured, &body) - } - }), - )) + let replacement = spawn_gateway( + Router::new() + .route( + "/v1/chat/completions", + post(move |headers: axum::http::HeaderMap, body: String| { + let captured = Arc::clone(&captured); + async move { + if headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + != Some("Bearer replacement-key") + { + return StatusCode::UNAUTHORIZED.into_response(); + } + gate_completions(&captured, &body) + } + }), + ) + // The relaunch resolves its model through the replacement's + // catalog; without one it binds the fallback window and the + // chat role's minimum refuses the run. + .route("/v1/models", get(gate_models)), + ) .await; replace_gateway( &gateway_updater(&server.state), diff --git a/crates/workshop-sessions/agents/chat.md b/crates/workshop-sessions/agents/chat.md index 67918599..c018516b 100644 --- a/crates/workshop-sessions/agents/chat.md +++ b/crates/workshop-sessions/agents/chat.md @@ -8,7 +8,9 @@ tools: fetch: promptforge/web/fetch search: promptforge/web/search models: - chat: {} + chat: + min_context: 32768 + description: "A conversational frontier model suitable for long-lived operator sessions" --- # Chat diff --git a/crates/workshop-sessions/src/agents/environment.rs b/crates/workshop-sessions/src/agents/environment.rs index 3d582512..eff47e0a 100644 --- a/crates/workshop-sessions/src/agents/environment.rs +++ b/crates/workshop-sessions/src/agents/environment.rs @@ -335,7 +335,7 @@ mod tests { get(|| async { axum::Json(serde_json::json!({ "object": "list", - "data": [{ "id": "test-model", "description": "d", "context": 8192, "thinking": "never" }], + "data": [{ "id": "test-model", "description": "d", "context": 200_000, "thinking": "never" }], })) }), ); diff --git a/crates/workshop-sessions/src/agents/tests.rs b/crates/workshop-sessions/src/agents/tests.rs index 84f8d29d..ff99af0c 100644 --- a/crates/workshop-sessions/src/agents/tests.rs +++ b/crates/workshop-sessions/src/agents/tests.rs @@ -280,12 +280,13 @@ fn the_model_client_requires_a_usable_key_and_url() { assert!(agent_client("not a url", "k").is_none()); } -/// The descriptor the chat unit runs bind the declared `chat` role to. +/// The descriptor the chat unit runs bind the declared `chat` role to; its +/// window clears the role's declared minimum. fn test_model() -> ModelDescriptor { ModelDescriptor::new( ModelId::gateway("test-model").expect("the test model id is valid"), "test model", - NonZeroU32::new(8192).expect("8192 is non-zero"), + NonZeroU32::new(200_000).expect("200000 is non-zero"), ThinkingMode::Never, ) } @@ -341,9 +342,47 @@ fn the_builtin_chat_declares_its_contract_in_frontmatter() { assert_eq!(tools.len(), 2, "both web tools get exact slots"); assert!(tools.get("fetch").is_some(), "the fetch slot is declared"); assert!(tools.get("search").is_some(), "the search slot is declared"); + let chat = frontmatter + .models() + .get("chat") + .expect("the chat role is declared for the host's current model"); + assert_eq!( + chat.min_context(), + NonZeroU32::new(32768), + "the role declares the window its web tools need, so an undersized \ + binding is refused at prepare instead of failing mid-conversation" + ); +} + +#[tokio::test] +async fn an_undersized_model_is_refused_before_the_first_turn() { + // The scenario this pins: a launch that bound a small descriptor (the + // catalog fetch's fallback window) used to run, then fail the first + // conversation that outgrew it. The declared minimum turns that into a + // refusal at prepare naming the role. + use promptforge_api::execute::RunErrorKind; + use promptforge_api::{Prompt, RunContext, RunResult}; + 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 env = session_environment("http://127.0.0.1:9", "k") + .expect("a well-shaped gateway root builds the session environment"); + let small = ModelDescriptor::new( + ModelId::gateway("small-model").expect("the test model id is valid"), + "small model", + NonZeroU32::new(8192).expect("8192 is non-zero"), + ThinkingMode::Never, + ); + let ctx = RunContext::new("chat-unit").observer(observer).model(small); + + let RunResult::Failure(error) = env.run(&prompt, "", ctx).await else { + panic!("an 8192-token model cannot satisfy the chat role"); + }; + assert_eq!(error.kind(), RunErrorKind::RequirementsUnmet); + let notice = error.to_string(); assert!( - frontmatter.models().get("chat").is_some(), - "the chat role is declared for the host's current model" + notice.contains("chat") && notice.contains("32768") && notice.contains("8192"), + "the notice names the role, the minimum, and the actual window: {notice}" ); } From 6f1703491529462726f95e25448c8f127572da81 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 03:07:16 -0700 Subject: [PATCH 23/30] Re-type ToolId::capability() to return CapabilityId The accessor that yields a tool's contributing capability now returns the nominal capability identity type rather than a generic global name. A crate-internal constructor builds that identity directly from the already-validated two-segment prefix of a parsed tool identity, so every capability comparison is typed equality with no text round-trip or re-parse. Callers, doctests, and tests move to the typed form. - `CapabilityId::from_prefix` - New crate-internal constructor in crates/shared-promptforge-api/src/capabilities.rs from a `GlobalName` prefix; a debug_assert pins the exactly-two-segments invariant the tool id parser already established. - `ToolId::capability` - Return type changes from `GlobalName` to `CapabilityId`, a public signature change in crates/shared-promptforge-api/src/tools/ids.rs built on the new constructor with no re-parse. - `fill_tool_bindings` - The rejected-contribution arm compares the typed accessor result against the activated set directly; the stringify-and-revalidate round-trip and its explanatory comment are gone. - `contains` - The capability containment check compares the typed accessor result against `*self` instead of the inner name. - `CapabilityId::parse` - Doctests and assertions in the touched crates migrate to typed equality against it, replacing display-string comparisons. Design: new encapsulated-invariant @ crates/shared-promptforge-api/src/capabilities.rs::CapabilityId::from_prefix deps: GlobalName Design: new surface-growth @ crates/shared-promptforge-api/src/tools/ids.rs::ToolId::capability boundary: pub Plan: vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md --- crates/promptforge-api/src/execute/fill.rs | 4 +- crates/promptforge-tool-picker/src/catalog.rs | 6 +- .../src/picker/tests.rs | 11 +- .../src/web_search/tests.rs | 6 +- crates/promptforge-webfetch/src/tool.rs | 6 +- .../src/capabilities.rs | 16 +- .../shared-promptforge-api/src/tools/ids.rs | 21 +- .../shared-promptforge-api/src/tools/tests.rs | 14 +- ...-1-debt-removal-capabilities-follow-ups.md | 210 ++++++++++++++++++ vibe/ACTIVE | 1 + 10 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md create mode 100644 vibe/ACTIVE diff --git a/crates/promptforge-api/src/execute/fill.rs b/crates/promptforge-api/src/execute/fill.rs index f35eaa11..7c0fed2a 100644 --- a/crates/promptforge-api/src/execute/fill.rs +++ b/crates/promptforge-api/src/execute/fill.rs @@ -113,9 +113,7 @@ pub(super) fn fill_tool_bindings( tracing::info!(alias, tool = %id, "tool slot filled"); bindings.bind(alias, tool); } else { - // The parser validated the path's arity, so its first - // two segments are a valid capability id. - let capability = CapabilityId::from_validated(&id.capability().to_string()); + let capability = id.capability(); if activated.contains(&capability) { // The capability is active but the tool is not in // the catalog: the contribution was rejected at diff --git a/crates/promptforge-tool-picker/src/catalog.rs b/crates/promptforge-tool-picker/src/catalog.rs index 78395ab6..be9d021b 100644 --- a/crates/promptforge-tool-picker/src/catalog.rs +++ b/crates/promptforge-tool-picker/src/catalog.rs @@ -481,7 +481,11 @@ mod tests { assert_ne!(id, tid("blobs", "read_file")); assert_ne!(id, tid("files", "write_file")); assert_eq!(id.name(), "read_file"); - assert_eq!(id.capability().to_string(), "tests/files"); + assert_eq!( + id.capability(), + shared_promptforge_api::capabilities::CapabilityId::parse("tests/files") + .expect("a valid capability id") + ); assert_eq!(id.to_string(), "tests/files/read_file"); } diff --git a/crates/promptforge-tool-picker/src/picker/tests.rs b/crates/promptforge-tool-picker/src/picker/tests.rs index eb760641..a33db83b 100644 --- a/crates/promptforge-tool-picker/src/picker/tests.rs +++ b/crates/promptforge-tool-picker/src/picker/tests.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, OnceLock}; use serde_json::json; use shared_progress::{EventState, ProgressHub}; +use shared_promptforge_api::capabilities::CapabilityId; use super::ToolPicker; use crate::catalog::{Catalog, ToolDescriptor, ToolId}; @@ -238,8 +239,14 @@ fn near_duplicates_reuses_the_indexed_vectors_inclusively() { let pairs = picker.near_duplicates(&ids).expect("analysis"); assert_eq!(pairs.len(), 1); let pair = pairs.get(0).expect("one pair"); - assert_eq!(pair.first().id().capability().to_string(), "tests/files"); - assert_eq!(pair.second().id().capability().to_string(), "tests/blobs"); + assert_eq!( + pair.first().id().capability(), + CapabilityId::parse("tests/files").expect("a valid capability id") + ); + assert_eq!( + pair.second().id().capability(), + CapabilityId::parse("tests/blobs").expect("a valid capability id") + ); assert!(pair.similarity() >= picker.config().duplicate_threshold()); } diff --git a/crates/promptforge-web-search/src/web_search/tests.rs b/crates/promptforge-web-search/src/web_search/tests.rs index 6d96de0e..fee37a8d 100644 --- a/crates/promptforge-web-search/src/web_search/tests.rs +++ b/crates/promptforge-web-search/src/web_search/tests.rs @@ -158,7 +158,11 @@ fn the_migrated_id_names_its_contributing_capability() { let tool = WebSearch::new("http://localhost", "test").expect("valid web search configuration"); let id = tool.id(); assert_eq!(id.name(), "search"); - assert_eq!(id.capability().to_string(), "promptforge/web"); + assert_eq!( + id.capability(), + shared_promptforge_api::capabilities::CapabilityId::parse("promptforge/web") + .expect("a valid capability id") + ); } #[tokio::test] diff --git a/crates/promptforge-webfetch/src/tool.rs b/crates/promptforge-webfetch/src/tool.rs index 6c9059da..e03a29a0 100644 --- a/crates/promptforge-webfetch/src/tool.rs +++ b/crates/promptforge-webfetch/src/tool.rs @@ -508,7 +508,11 @@ mod tests { // last segment must yield the contributing capability's id. let id = WebFetch::new().id(); assert_eq!(id.name(), "fetch"); - assert_eq!(id.capability().to_string(), "promptforge/web"); + assert_eq!( + id.capability(), + shared_promptforge_api::capabilities::CapabilityId::parse("promptforge/web") + .expect("a valid capability id") + ); } #[derive(Clone)] diff --git a/crates/shared-promptforge-api/src/capabilities.rs b/crates/shared-promptforge-api/src/capabilities.rs index 9e2f05bc..12f1a7b7 100644 --- a/crates/shared-promptforge-api/src/capabilities.rs +++ b/crates/shared-promptforge-api/src/capabilities.rs @@ -82,6 +82,20 @@ impl CapabilityId { CapabilityId(name) } + /// Builds an identity from a 2-segment prefix split off a validated + /// tool id. + /// + /// Crate-internal: backs [`crate::tools::ToolId::capability`]. The + /// source tool id was validated at parse, so its first two segments + /// are already a valid capability id and need no re-parse. + pub(crate) fn from_prefix(prefix: GlobalName) -> CapabilityId { + debug_assert!( + prefix.segments().len() == 2, + "a tool id's capability prefix must have exactly 2 segments (namespace/pack)" + ); + CapabilityId(prefix) + } + /// Returns the namespace segment (reverse-DNS or `promptforge`). /// /// # Examples @@ -137,7 +151,7 @@ impl CapabilityId { /// ``` #[must_use] pub fn contains(&self, tool: &ToolId) -> bool { - tool.capability() == self.0 + tool.capability() == *self } } diff --git a/crates/shared-promptforge-api/src/tools/ids.rs b/crates/shared-promptforge-api/src/tools/ids.rs index 7069d563..33e6375e 100644 --- a/crates/shared-promptforge-api/src/tools/ids.rs +++ b/crates/shared-promptforge-api/src/tools/ids.rs @@ -1,5 +1,6 @@ //! Stable tool identity and its validation errors. +use crate::capabilities::CapabilityId; use crate::names::{GlobalName, GlobalNameErrorKind}; /// The stable identity of a live tool. @@ -29,12 +30,13 @@ impl ToolId { /// # Examples /// /// ``` + /// use shared_promptforge_api::capabilities::CapabilityId; /// use shared_promptforge_api::tools::ToolId; /// /// let id = ToolId::parse("promptforge/web/fetch")?; /// assert_eq!(id.name(), "fetch"); - /// assert_eq!(id.capability().to_string(), "promptforge/web"); - /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) + /// assert_eq!(id.capability(), CapabilityId::parse("promptforge/web")?); + /// # Ok::<(), Box>(()) /// ``` pub fn parse(id: &str) -> Result { let name = @@ -84,22 +86,23 @@ impl ToolId { /// Returns the contributing capability's id: the first two segments. /// /// Containment is total - dropping the last segment of any tool id always - /// yields the id of the capability that contributed it. The return type - /// re-types to the capabilities module's `CapabilityId` when that module - /// lands; the value is already exactly that id. + /// yields the id of the capability that contributed it. The prefix was + /// validated when the tool id was parsed, so it builds the + /// [`CapabilityId`] directly, with no re-parse. /// /// # Examples /// /// ``` + /// use shared_promptforge_api::capabilities::CapabilityId; /// use shared_promptforge_api::tools::ToolId; /// /// let id = ToolId::parse("promptforge/web/fetch")?; - /// assert_eq!(id.capability().to_string(), "promptforge/web"); - /// # Ok::<(), shared_promptforge_api::tools::ToolIdError>(()) + /// assert_eq!(id.capability(), CapabilityId::parse("promptforge/web")?); + /// # Ok::<(), Box>(()) /// ``` #[must_use] - pub fn capability(&self) -> GlobalName { - self.0.capability_prefix() + pub fn capability(&self) -> CapabilityId { + CapabilityId::from_prefix(self.0.capability_prefix()) } } diff --git a/crates/shared-promptforge-api/src/tools/tests.rs b/crates/shared-promptforge-api/src/tools/tests.rs index d2d96812..41765a5f 100644 --- a/crates/shared-promptforge-api/src/tools/tests.rs +++ b/crates/shared-promptforge-api/src/tools/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use super::{Tool, ToolCatalog, ToolCatalogErrorKind, ToolError, ToolId, ToolOutput}; -use crate::names::GlobalName; +use crate::capabilities::CapabilityId; fn inspect_id() -> ToolId { ToolId::parse("fixtures/tools/inspect").expect("fixture id is valid") @@ -238,7 +238,7 @@ fn a_tool_ids_capability_is_always_its_two_segment_prefix() { let id = ToolId::parse("promptforge/web/fetch").expect("a valid tool id"); assert_eq!( id.capability(), - GlobalName::parse("promptforge/web").expect("a valid capability name"), + CapabilityId::parse("promptforge/web").expect("a valid capability id"), "dropping the last segment must yield the contributing capability's id" ); } @@ -247,7 +247,10 @@ fn a_tool_ids_capability_is_always_its_two_segment_prefix() { fn containment_holds_for_a_reverse_dns_namespace() { let id = ToolId::parse("org.rustalliance/core/search").expect("a valid tool id"); assert_eq!(id.name(), "search"); - assert_eq!(id.capability().to_string(), "org.rustalliance/core"); + assert_eq!( + id.capability(), + CapabilityId::parse("org.rustalliance/core").expect("a valid capability id") + ); } #[test] @@ -312,7 +315,10 @@ fn an_uppercase_segment_is_rejected_because_comparison_is_case_sensitive() { fn from_validated_builds_a_static_id_without_revalidating() { let id = ToolId::from_validated("promptforge/web/search"); assert_eq!(id.name(), "search"); - assert_eq!(id.capability().to_string(), "promptforge/web"); + assert_eq!( + id.capability(), + CapabilityId::parse("promptforge/web").expect("a valid capability id") + ); } #[test] diff --git a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md new file mode 100644 index 00000000..3965369d --- /dev/null +++ b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md @@ -0,0 +1,210 @@ +--- +name: "Debt removal: capabilities follow-ups" +overview: "Remove the three accepted debts from the capabilities/global-naming range (upstream/master..eeca0a16) - re-type ToolId::capability() to CapabilityId, reject punctuation-twin capability ids at registration, delete the dead-on-arrival 8192 fallback model descriptor in Workshop - plus the exposed pre-existing PF-EXP-01: bound-tool dispatch failures become model-readable tool results instead of aborting the model loop." +todos: + - id: retype-capability-accessor + content: Re-type ToolId::capability() to CapabilityId, delete the fill.rs string round-trip, migrate GlobalName-comparing consumers (PF-DEBT-01) + status: pending + - id: registry-twin-rejection + content: Reject punctuation-normalized capability id twins at registry registration with RegistryErrorKind::NormalizationCollision (PF-DEBT-02) + status: pending + - id: remove-fallback-descriptor + content: Delete the 8192 fallback descriptor; report catalog fetch failure as the chat launch error (PF-DEBT-03) + status: pending + - id: tool-errors-as-results + content: Convert bound-tool dispatch failures into model-readable tool results in the model loop (PF-EXP-01) + status: pending +isProject: false +--- + +# Debt Removal: Capabilities Follow-ups + + + +## Product Requirements + +A debt-collector pass over the capabilities/global-naming range (`upstream/master` `e615c2ea`..`eeca0a16`, 22 commits) accepted three introduced debts, and the operator added one exposed pre-existing debt to scope. All four are small, local, and independently shippable. + +- Problem and users: four debts from the capabilities work now tax the system: a type split between `ToolId` and `CapabilityId` bridged by a string round-trip; a registry that admits punctuation-twin capability ids into model-facing text; a fallback model descriptor whose documented purpose is unreachable; and a model loop that aborts on any bound-tool error, hiding the tool's model-facing message from both the model and the operator. Users are capability authors, hosts (Workshop), and models reading tool output. +- Goals: + - PF-DEBT-01: `ToolId::capability()` returns `CapabilityId`; no string round-trip or untyped `GlobalName` comparison remains at any seam. + - PF-DEBT-02: `CapabilityRegistry::register` rejects a capability id that differs from an existing registration only by `-`/`_`/`.` punctuation, with `RegistryErrorKind::NormalizationCollision` naming both ids. + - PF-DEBT-03: the 8192-token fallback descriptor in `crates/workshop-sessions/src/agents/environment.rs` is deleted; a catalog fetch failure at chat launch is reported as the launch error naming the fetch as cause. + - PF-EXP-01: a bound tool's own `ToolError` becomes the call's tool result - untrusted-wrapped, model-readable - and the run continues; cancellation, quota, determinism, internal, out-of-scope, and local-tool errors stay fatal. +- Non-goals: no `GlobalName::parse` change (collision is relational, not a parse property); no `Observation` enum payload (the existing tool-result channel already carries the error text to journal and SPA); no `OutOfScopeToolCall` conversion (enforced hiding is a separate security-adjacent decision); no local-tool or Lua `tools.call` error-semantics change (author bugs keep raising); no charset narrowing; no structural ratchets; no change to `min_context` values. +- Success criteria: the four target states above hold; the full workspace suite, doctests, clippy `-D warnings`, and `cargo fmt --all --check` are green. +- Constraints: behavior changes ship with their tests in the same change; user-facing strings are model-facing strings (concise, factual, self-contained) per the root `AGENTS.md` Principles rule; the software is pre-release with first-party hosts only. +- Open questions: none. + +## Functional Specification + +Each work item changes one observable behavior; everything else is invariant. + +- Actors and workflows: capability authors register packs into `CapabilityRegistry`; Workshop launches chat sessions and resolves the dropdown's model per run; models call bound tools through `models.loop`. +- Inputs and outputs: + - `ToolId::capability() -> CapabilityId` (was `GlobalName`). + - `register` returns `RegistryError` with kind `NormalizationCollision` for punctuation twins; the message names both ids. + - `current_model` in `crates/workshop-sessions/src/agents/environment.rs` reports its failure causes (catalog fetch failed; selection absent from the fetched catalog) instead of binding a fallback descriptor; the caller in `crates/workshop-sessions/src/agents/supervisor/effects.rs` (~160-171) turns them into a launch-time error report. + - In `crates/promptforge-api/src/execute/tool_loop.rs`, the `DispatchTarget::Bound` arm converts `Error::Tool` into the call's result record; all other error classes propagate unchanged. +- States and validation: punctuation normalization maps `-`/`_`/`.` to one canonical byte per segment; case needs no handling (the charset is lowercase-only at parse). The registry's exact-duplicate rejection (`DuplicateId`) is unchanged. +- Errors and recovery: a failed tool call produces a tool result whose content is the `ToolError` message, nonce-wrapped as untrusted (it embeds upstream content); the model adapts, retries, or reports. A model that keeps calling the failing tool exits at `max_tool_iterations` as today. +- Security and privacy behavior: error text is wrapped untrusted like any third-party-embedding content; unadvertised-alias calls stay fatal (enforced hiding unchanged). +- Acceptance criteria: + - Grep finds no `capability().to_string()` and no `GlobalName`-typed capability comparison. + - Registering `acme/web-search` then `acme/web_search` (or `acme/web.search`) fails with `NormalizationCollision` naming both; distinct non-twin names register fine. + - A chat launch whose catalog fetch fails reports the fetch failure; no 8192 descriptor is ever bound. + - A failing bound tool yields an untrusted tool result carrying its message, the loop continues to a terminal reply, and the `ToolCallFailed` observation still fires. + + + + +## Technical Design + +Four independent, local changes. Only PF-DEBT-01 touches a public signature; it completes the return type the capabilities plan's live declaration (`vibe/2026-09-13-1-capabilities-global-naming.md`) always specified. + +- Architecture: + - PF-DEBT-01: one nominal type for capability identity. `CapabilityId` gains a crate-internal constructor from a prefix known 2-segment; `ToolId::capability()` builds on it directly, with no re-parse. + - PF-DEBT-02: rejection lives at `CapabilityRegistry::register` (`crates/promptforge-api/src/capabilities.rs`), never at parse. The capabilities plan's deferred declarations anticipated exactly this variant ("`GlobalNameError` and `RegistryError` each gain NormalizationCollision"). The existing description near-duplicate lint stays advisory and unchanged. + - PF-DEBT-03: `current_model` returns the descriptor or a reported cause (a `Result` with a small error enum, or an enum of outcomes); the 8192 `FALLBACK_CONTEXT` constant and the fallback arm are deleted. The boot-window path (no selection yet, first catalog model) keeps working when the fetch succeeds. + - PF-EXP-01: the Lua `tools.call` arm already delivers dispatch failure as a catchable value (`crates/promptforge-api/src/execute/scheduler.rs`, `dispatch_tool_call` ~1576-1587); the model loop is the only abort-on-tool-error path, and this change extends the existing mechanism to it. `dispatch_tool` (`crates/promptforge-lua/src/dispatch.rs` ~87-147) is unchanged: it fires `TOOL_CALL_FAILED` before returning, so the SPA failure frame from `232204cf` keeps working, and the loop's existing `on_tool_result` records the error text as the result content. +- Modules and interfaces: `shared-promptforge-api` (`ToolId::capability()` re-typed; `CapabilityId` constructor), `promptforge-api` (`RegistryErrorKind::NormalizationCollision`; the `tool_loop.rs` Bound arm), `workshop-sessions` (`current_model` signature and its caller). +- File and public API changes: `crates/shared-promptforge-api/src/tools/ids.rs` and `capabilities.rs`; `crates/promptforge-api/src/capabilities.rs`, `execute/fill.rs` (the ~118 round-trip deleted), `execute/tool_loop.rs`; consumers in `crates/promptforge-model-client/src/model.rs` and `crates/promptforge-tool-picker/src/policy.rs` migrate to the typed form; `crates/workshop-sessions/src/agents/environment.rs` and `agents/supervisor/effects.rs`; `guide/src/language/07-tools.md` documents tool failures arriving as tool results, and the assembled guide is regenerated. +- Data, persistence, failure, security, and privacy constraints: the empty-reply exit counter in `tool_loop.rs` (`successful_tool_calls`, ~165) counts any call that received a result record, error included - rename to match; the round's atomic append (assistant calls plus one result record per call) is preserved; no journal or replay consumer exists to re-teach (the durable tier is out of scope for the product). + + + + +## Testing Plan + +Each item lands with its behavior tests in the same change; the abort-to-result migration re-pins the one test that asserted the old semantics. + +- Unit: + - PF-DEBT-01: existing accessor and containment tests updated to the typed return; the four touched crates compile as the primary proof. + - PF-DEBT-02: new registry tests - `acme/web-search` then `acme/web_search` fails with `NormalizationCollision` naming both; same for `acme/web.search`; exact duplicate still `DuplicateId`; punctuation-distinct non-twins (`acme/web-search` vs `acme/web-search-extra`) register fine; the description-lint tests are unaffected. + - PF-DEBT-03: replace `a_failed_catalog_fetch_binds_the_fallback_descriptor` with a test pinning that a fetch failure produces the reported launch error naming the fetch as cause; keep `no_selection_and_no_catalog_means_no_model`. + - PF-EXP-01: the loop's abort-pinning test (`crates/promptforge-api/src/execute/tests/tool_loop.rs` ~319) migrates to pin error-as-result (result record carries the message, untrusted-wrapped, loop continues to a terminal reply). New tests: cancellation still aborts mid-dispatch; a quota (counts) failure still aborts; a local-tool handler error still aborts; `TOOL_CALL_FAILED` fires alongside the error result; repeated calls to the failing tool exit at `max_tool_iterations`. +- Integration and end-to-end: workshop-sessions suite and the workshop-server chat gates stay green through PF-DEBT-03; the chat end-to-end test (`a_chat_session_activates_the_web_capability_and_calls_search_end_to_end`) is unaffected by PF-EXP-01 because its mock search succeeds. +- Regression, security, and performance: the Lua `tools.call` and local-tool suites are unchanged and green; the guide builds (`mdbook build guide`) with the regenerated assembled guide. +- Exit criteria: full workspace nextest, doctests, clippy `-D warnings`, `cargo fmt --all --check` all green. + + + + +## Decision Record + +- Decisions: + - PF-DEBT-01 remedy is the re-type, not a crate-internal conversion shim: the capabilities plan's live declaration always specified `pub fn capability(&self) -> CapabilityId`; the `GlobalName` return was interim with a falsifier that fired when `CapabilityId` landed (`1d0c2a5f`). Consequence: one-pass consumer migration. + - PF-DEBT-02 remedy is rejection at registration: user-resolved 2026-09-14 ("do actual rejection"), superseding the capabilities plan's deferral - "registry scale" arrived when the registry and the first-party pack landed in-range. Rejection lives at `register`, never at `GlobalName::parse`, because collision is relational. + - PF-DEBT-03 remedy is deletion: user-resolved 2026-09-14 ("do deletion"). The fallback caused two corrective episodes in one night (`090fc747`, `eeca0a16`) and zero successful degradations; its only consumer now refuses it by construction (`min_context: 32768` in `crates/workshop-sessions/agents/chat.md`). + - PF-EXP-01 remedy is error-as-result at the model-dispatch boundary: user-resolved 2026-09-14 (scope expanded after the user asked "can we fix this reliably?"). Two same-night outages (an invisible 404, an invisible context refusal) would have been self-explaining to the model under this behavior. +- Rejected alternatives: + - Advisory lint only for name twins: weaker than the approved remedy; the description lint already covers the advisory channel. Revisit never. + - Charset narrowing to one separator: breaks the shipped `web-search`/`web_fetch` wire names' descendants and dotted reverse-DNS namespaces. Revisit never. + - Raising `FALLBACK_CONTEXT` above 32768: fabricates a window the model may not have and moves the failure provider-side. Revisit never. + - Mark-degraded binding: still binds unreliable metadata. Revisit never. + - Payload on `Observation::ToolCallFailed`: redundant once the error text flows through the existing tool-result channel to the journal and SPA. Revisit never. + - Converting `OutOfScopeToolCall` to a result: enforced hiding is a separate security-adjacent decision. Revisit when the deferred discovery capability lands. + - Converting local-tool handler errors: author bugs should abort. Revisit never. +- Assumptions, risks, and notes: + - Pre-release with first-party hosts only: the `capability()` signature change and the loop's behavior change have no external consumers to defend. + - `RunErrorKind::Tool` no longer covers bound-tool failures from the model loop; it remains for the Lua arm and local tools. + - The debt-collector findings and challenge (2026-09-14, disposition ref `eeca0a16`) upheld all four items; 21 other candidates were rejected (11 residual-but-acceptable, 5 weak/speculative, 5 false at the disposition ref) and are deliberately not in scope. + +### Deferred and Out of Scope + +- Deferred: nothing new; every deferral from the capabilities plan (the prompt-pack, the open toolset, the `prompt` global, bridge capabilities, versioning) stands untouched. +- Out of scope: the residual-but-acceptable candidates from the debt pass - they are deferrals with named landing points or reviewed policies, not ripe debt. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build` (default-members builds only the gateway, which compiles on a fresh clone with no CUDA or Tauri system packages; the desktop app is an explicit `cargo build -p workshop`) +- Focused test command pattern: `cargo nextest run --locked -p ` +- Component test command pattern: `cargo nextest run --locked -p ` (workshop crates: `-p workshop -p workshop-server`) +- Full-suite test command: `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 command: `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 check command: `cargo fmt --all --check` +- Docs command: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server` with `RUSTDOCFLAGS="-D warnings"`; user guide: `mdbook build guide` +- Test placement and naming conventions: unit tests live inline in `src/` files under `#[cfg(test)]` modules; integration tests live in `crates//tests/`, most commonly as a single `it` target (`tests/it/main.rs` with submodules), with some crates using named targets (e.g. `engine_contract.rs`, `interruption.rs`); shared helpers and data live in `tests/common/` and `tests/fixtures/`; boundary and structural harness runs via `cargo test -p build-xtask`; nextest config in `.config/nextest.toml` defines a `heavy` test group (max-threads 2) for promptforge-tool-picker, gateway-stt-backend-whisper, and gateway-stt +- Directory map: `crates/` holds all workspace members (Cargo glob `crates/*`, excluding `crates/shared-ui`, a TypeScript+CSS package); `guide/` is the mdbook user guide; `prompts/` holds PromptForge prompt files; `tools/` holds repo tooling; `vibe/` holds session logs and `archdoc.md`; `images/` holds assets; `local/` and `target*/` are build/local output; `.config/` holds nextest config, `.githooks/` git hooks, `.github/` CI +- Component boundaries (per `vibe/archdoc.md` and AGENTS.md): three products - PromptForge (executor/runtime, `promptforge-*` crates), Gateway (inference service, `gateway-*` crates), Workshop (Tauri desktop, `workshop-*` crates) - plus `shared-*` substrate crates and `build-*` output builders; dependency rules: workshop-* never depends on gateway-*, gateway-* never depends on promptforge-*/workshop-*, promptforge-* never depends on gateway-*/workshop-*, shared-* depends on no product crates, and crates outside promptforge-* may depend only on promptforge-api (the one-door rule); executor depends on gateway, store, Lua VM boundary, and shared substrate; VFS layer (`shared-vfs` plus the `promptforge-vfs` policy gate) depends on nothing +- Conventions summary: Rust edition 2024, stable toolchain (`rust-toolchain.toml`); workspace lints forbid unsafe_code and deny clippy `all`, `unwrap_used`, `expect_used`; no file exceeds 500 lines (enforced by build-xtask); every workshop-* crate's lib.rs opens with a `## Invariants` doc marker listing allowed dependencies; dependencies flow shell -> features -> services -> vocabulary; behavior changes ship with tests in the same change; error messages are written for model consumption (concise, factual, self-contained); Cargo features gate real constraints (toolchain, native builds), not product shape; CSS lives beside its TypeScript in self-contained feature directories using `--ws-*` design tokens; comments cite upstream issue URLs for workarounds + + + + +## Execution Instructions + +Components in dependency order: + +1. `capability-id-retype` (PF-DEBT-01) - first: it changes the shared vocabulary crate (`shared-promptforge-api`) that the other PromptForge components build against, and it completes the public signature the capabilities plan always declared. +2. `registry-twin-rejection` (PF-DEBT-02) - second: it edits the same file (`crates/promptforge-api/src/capabilities.rs`) as the re-type's containment-check migration; landing adjacent avoids same-file rebase churn. +3. `fallback-descriptor-removal` (PF-DEBT-03) - third: isolated to `workshop-sessions`; no coupling to the PromptForge components. +4. `tool-errors-as-results` (PF-EXP-01) - last: widest test surface (one migrated abort-pinning test plus five new behavior tests) and a guide regeneration; landing last puts the exit gate immediately after the largest change. + + + +### Step 1: re-type ToolId::capability() to CapabilityId [completed] + +- Component: capability-id-retype +- Piece: typed-accessor-and-consumers (single piece, joint construction: the signature change and the consumer migrations must compile together in one commit) +- Change: in `crates/shared-promptforge-api/src/capabilities.rs`, add a crate-internal `CapabilityId` constructor from a prefix known 2-segment; in `crates/shared-promptforge-api/src/tools/ids.rs`, re-type `ToolId::capability()` to return `CapabilityId` built on that constructor with no re-parse; delete the string round-trip in `crates/promptforge-api/src/execute/fill.rs` (~118); migrate the containment check in `crates/promptforge-api/src/capabilities.rs` and the `GlobalName`-comparing consumers in `crates/promptforge-model-client/src/model.rs` and `crates/promptforge-tool-picker/src/policy.rs` to the typed form. +- Tests: update the existing accessor and containment tests to the typed return; the four touched crates compiling is the primary proof. +- Verify: `cargo nextest run --locked -p shared-promptforge-api -p promptforge-api -p promptforge-tool-picker -p promptforge-model-client`; clippy on the same crates with `-D warnings`; grep finds no `capability().to_string()` and no `GlobalName`-typed capability comparison. + + + + + +### Step 2: reject punctuation-twin capability ids at registration + +- Component: registry-twin-rejection +- Piece: normalization-collision-rejection (single piece: one error kind, one register check, one test set) +- Change: in `crates/promptforge-api/src/capabilities.rs`, add `RegistryErrorKind::NormalizationCollision`; at `CapabilityRegistry::register`, reject a capability id that differs from an existing registration only by `-`/`_`/`.` punctuation (normalize each segment to one canonical byte per separator; case needs no handling, the charset is lowercase-only at parse), with a model-facing message naming both ids; leave `GlobalName::parse`, the exact-duplicate `DuplicateId` path, and the advisory description near-duplicate lint unchanged. +- Tests: new registry tests - `acme/web-search` then `acme/web_search` fails with `NormalizationCollision` naming both; same for `acme/web.search`; exact duplicate still `DuplicateId`; punctuation-distinct non-twins (`acme/web-search` vs `acme/web-search-extra`) register fine; the description-lint tests are unaffected. +- Verify: `cargo nextest run --locked -p promptforge-api`. + + + + + +### Step 3: delete the 8192 fallback model descriptor + +- Component: fallback-descriptor-removal +- Piece: catalog-failure-reporting (single piece, joint construction: the `current_model` signature change, the fallback deletion, and the caller's error reporting compile together) +- Change: in `crates/workshop-sessions/src/agents/environment.rs`, delete the `FALLBACK_CONTEXT` constant and the fallback arm of `current_model`; change `current_model` to return the descriptor or a reported cause (a `Result` with a small error enum, or an enum of outcomes) covering catalog-fetch-failed and selection-absent-from-the-fetched-catalog; in `crates/workshop-sessions/src/agents/supervisor/effects.rs` (~160-171), turn those causes into the chat launch error naming the fetch as cause; keep the boot-window path (no selection yet, first catalog model) working when the fetch succeeds; no `min_context` values change. +- Tests: replace `a_failed_catalog_fetch_binds_the_fallback_descriptor` with a test pinning that a fetch failure produces the reported launch error naming the fetch as cause; keep `no_selection_and_no_catalog_means_no_model`. +- Verify: `cargo nextest run --locked -p workshop-sessions`; the workshop-server chat gates stay green (`cargo nextest run --locked -p workshop -p workshop-server`). + + + + + +### Step 4: convert bound-tool dispatch failures into tool results + +- Component: tool-errors-as-results +- Piece: bound-arm-error-as-result (sequential before the guide piece: behavior lands first, docs describe landed behavior) +- Change: in `crates/promptforge-api/src/execute/tool_loop.rs`, convert the `DispatchTarget::Bound` arm so a tool's own `Error::Tool` becomes the call's result record - content is the `ToolError` message, nonce-wrapped as untrusted - and the run continues; cancellation, quota, determinism, internal, out-of-scope, and local-tool errors propagate unchanged; rename the `successful_tool_calls` counter (~165) to match its answered-call semantics (any call that received a result record, error included); preserve the round's atomic append (assistant calls plus one result record per call); leave `dispatch_tool` (`crates/promptforge-lua/src/dispatch.rs` ~87-147) unchanged so `TOOL_CALL_FAILED` still fires before return and the loop's existing `on_tool_result` records the error text as the result content. +- Tests: migrate the abort-pinning test (`crates/promptforge-api/src/execute/tests/tool_loop.rs` ~319) to pin error-as-result (result record carries the message, untrusted-wrapped, loop continues to a terminal reply); new tests - cancellation still aborts mid-dispatch; a quota (counts) failure still aborts; a local-tool handler error still aborts; `TOOL_CALL_FAILED` fires alongside the error result; repeated calls to the failing tool exit at `max_tool_iterations`. +- Verify: `cargo nextest run --locked -p promptforge-api`; the Lua `tools.call` and local-tool suites are unchanged and green. + + + + + +### Step 5: document tool failures as tool results in the guide + +- Component: tool-errors-as-results +- Piece: guide-documentation (sequential after the bound-arm piece) +- Change: update `guide/src/language/07-tools.md` to document that a bound tool's own failure arrives as an untrusted-wrapped, model-readable tool result and the run continues; regenerate the assembled guide. +- Tests: none; docs-only step. +- Verify: `mdbook build guide` exits 0. + + + +Exit gate after the last step: full workspace nextest (`cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features`), doctests (`cargo test --workspace --exclude workshop --exclude workshop-server --all-features --doc`), workshop crates (`cargo nextest run --locked -p workshop -p workshop-server`), clippy `-D warnings` (workspace and workshop invocations per the Project Survey), `cargo fmt --all --check`. + + diff --git a/vibe/ACTIVE b/vibe/ACTIVE new file mode 100644 index 00000000..98944762 --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md From 89e800299bf0d8e789d9efb42f172d8bc2acf129 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 03:25:51 -0700 Subject: [PATCH 24/30] Reject punctuation-twin capability ids at registration The registry now rejects a capability whose id differs from an already registered id only by separator punctuation, because such twins would be indistinguishable to a model reading a catalog. The rejection message names both the rejected id and the registered id it collides with. Exact duplicates keep their existing rejection, and the advisory description lint is unchanged. - `RegistryError` drops the derived error implementation for a hand-written Display so the rejection message can name both ids, and carries the registered id a twin collides with in a new field behind a new accessor. - `normalize_id` and `normalize_segment` map every separator byte in each segment to one canonical byte, so two ids differing only in separator choice compare equal; case needs no handling because the charset is lowercase-only. - `CapabilityRegistry::register` runs the normalization check after the exact-duplicate check and rejects a twin before inserting, so the first registration survives and the twin never enters the registry. Design: new surface-growth @ crates/promptforge-api/src/capabilities.rs::RegistryErrorKind boundary: pub Design: new surface-growth @ crates/promptforge-api/src/capabilities.rs::RegistryError::collides_with boundary: pub Design: new pure-function @ crates/promptforge-api/src/capabilities.rs::normalize_id deps: CapabilityId Design: new pure-function @ crates/promptforge-api/src/capabilities.rs::normalize_segment deps: str Repairs: one capability per id up to separator punctuation @ crates/promptforge-api/src/capabilities.rs::CapabilityRegistry::register - a punctuation twin of a registered id registered successfully Plan: vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md --- crates/promptforge-api/src/capabilities.rs | 86 ++++++++++++++++++- .../promptforge-api/src/capabilities/tests.rs | 46 ++++++++++ ...-1-debt-removal-capabilities-follow-ups.md | 2 +- 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/promptforge-api/src/capabilities.rs b/crates/promptforge-api/src/capabilities.rs index 8db4c5ec..0c86f5b0 100644 --- a/crates/promptforge-api/src/capabilities.rs +++ b/crates/promptforge-api/src/capabilities.rs @@ -4,7 +4,10 @@ //! registry, registers each installed capability by hand, and hands the //! registry to the [`Environment`](crate::execute::Environment). v1 is //! unversioned - one capability per id - so a duplicate registration is -//! rejected rather than shadowing the installed capability. +//! rejected rather than shadowing the installed capability, and an id +//! differing from a registered id only by `-`/`_`/`.` punctuation is +//! rejected as a normalization collision: punctuation twins would be +//! indistinguishable to a model reading a catalog. //! //! Registration runs an advisory near-duplicate lint over capability //! descriptions through the picker (the engine behind fuzzy tool slots): @@ -107,13 +110,29 @@ impl CapabilityRegistry { /// # Errors /// Returns [`RegistryError`] with [`RegistryErrorKind::DuplicateId`] /// when a capability with the same id is already registered; the - /// registry keeps the first registration. + /// registry keeps the first registration. Returns [`RegistryError`] + /// with [`RegistryErrorKind::NormalizationCollision`] when the id + /// differs from an existing registration only by `-`/`_`/`.` + /// punctuation. pub fn register(&mut self, capability: Arc) -> Result<(), RegistryError> { let id = capability.id().clone(); if self.capabilities.contains_key(&id) { return Err(RegistryError { kind: RegistryErrorKind::DuplicateId, id, + collides_with: None, + }); + } + let normalized = normalize_id(&id); + if let Some(existing) = self + .capabilities + .keys() + .find(|existing| normalize_id(existing) == normalized) + { + return Err(RegistryError { + kind: RegistryErrorKind::NormalizationCollision, + id, + collides_with: Some(existing.clone()), }); } self.capabilities.insert(id.clone(), capability); @@ -209,17 +228,22 @@ fn lint_key(id: &CapabilityId) -> ToolId { pub enum RegistryErrorKind { /// A capability with the same id was already registered. DuplicateId, + /// The id differs from an existing registration only by `-`/`_`/`.` + /// punctuation: punctuation twins would be indistinguishable to a + /// model reading a catalog, so the second one is rejected. + NormalizationCollision, } /// The reason a capability registration was rejected. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("a capability with id {id} is already registered")] +#[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct RegistryError { /// A stable classification of the rejection. kind: RegistryErrorKind, /// The id whose registration was rejected. id: CapabilityId, + /// The registered id a punctuation twin collides with. + collides_with: Option, } impl RegistryError { @@ -234,4 +258,58 @@ impl RegistryError { pub fn id(&self) -> &CapabilityId { &self.id } + + /// Returns the registered id the rejected id collides with, when the + /// rejection is a [`RegistryErrorKind::NormalizationCollision`]. + #[must_use] + pub fn collides_with(&self) -> Option<&CapabilityId> { + self.collides_with.as_ref() + } +} + +impl fmt::Display for RegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.kind { + RegistryErrorKind::DuplicateId => { + write!( + formatter, + "a capability with id {} is already registered", + self.id + ) + } + RegistryErrorKind::NormalizationCollision => match &self.collides_with { + Some(existing) => write!( + formatter, + "capability id {} was rejected: it differs from the registered id {existing} only by '-', '_' or '.' punctuation", + self.id + ), + None => write!( + formatter, + "capability id {} was rejected: it differs from a registered id only by '-', '_' or '.' punctuation", + self.id + ), + }, + } + } +} + +impl std::error::Error for RegistryError {} + +/// Normalizes a capability id for the punctuation-twin check: each +/// separator byte (`-`, `_`, `.`) maps to one canonical byte, so two ids +/// differing only in separator choice compare equal. The global-name +/// charset is lowercase-only, so case needs no handling. +fn normalize_id(id: &CapabilityId) -> (String, String) { + ( + normalize_segment(id.namespace()), + normalize_segment(id.pack()), + ) +} + +/// Maps every separator byte in a segment to the canonical `-`. +fn normalize_segment(segment: &str) -> String { + segment + .chars() + .map(|c| if matches!(c, '-' | '_' | '.') { '-' } else { c }) + .collect() } diff --git a/crates/promptforge-api/src/capabilities/tests.rs b/crates/promptforge-api/src/capabilities/tests.rs index ef3e9f22..9f8e4264 100644 --- a/crates/promptforge-api/src/capabilities/tests.rs +++ b/crates/promptforge-api/src/capabilities/tests.rs @@ -139,6 +139,52 @@ fn registering_a_near_duplicate_description_fires_the_lint() { ); } +#[test] +fn a_punctuation_twin_of_a_registered_id_is_rejected() { + for twin in ["acme/web_search", "acme/web.search"] { + let mut registry = CapabilityRegistry::new(); + registry + .register(stub("acme/web-search", "Web tools.")) + .expect("the first registration succeeds"); + let error = registry + .register(stub(twin, "Other web tools.")) + .expect_err("a punctuation twin is rejected"); + assert_eq!(error.kind(), RegistryErrorKind::NormalizationCollision); + assert_eq!( + error.collides_with(), + Some(&capability_id("acme/web-search")) + ); + let message = error.to_string(); + assert!( + message.contains("acme/web-search"), + "the message names the registered id: {message}" + ); + assert!( + message.contains(twin), + "the message names the rejected id: {message}" + ); + // The rejected twin is not registered; the original survives. + assert!(registry.get(&capability_id(twin)).is_none()); + assert!(registry.get(&capability_id("acme/web-search")).is_some()); + } +} + +#[test] +fn punctuation_distinct_non_twins_register() { + let mut registry = CapabilityRegistry::new(); + registry + .register(stub("acme/web-search", "Web tools.")) + .expect("the first registration succeeds"); + registry + .register(stub("acme/web-search-extra", "Extra web tools.")) + .expect("a punctuation-distinct non-twin registers"); + assert!( + registry + .get(&capability_id("acme/web-search-extra")) + .is_some() + ); +} + #[test] fn distinct_descriptions_do_not_fire_the_lint() { let warnings = captured_warnings(|| { diff --git a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md index 3965369d..c46897c1 100644 --- a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md +++ b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md @@ -159,7 +159,7 @@ Components in dependency order: -### Step 2: reject punctuation-twin capability ids at registration +### Step 2: reject punctuation-twin capability ids at registration [completed] - Component: registry-twin-rejection - Piece: normalization-collision-rejection (single piece: one error kind, one register check, one test set) From 42d830ef95d9eb8795418c37102c44cc2ab3d7a0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 03:35:50 -0700 Subject: [PATCH 25/30] Report catalog failures instead of binding a fallback Launch-time model resolution no longer fabricates a descriptor under a hard-coded 8192-token window when the gateway catalog cannot be fetched or the selection is absent from it. Those conditions now surface as typed causes that fail the chat launch with the real reason named. The boot-window path, where no selection exists yet and the first catalog model stands in, keeps working when the fetch succeeds. - `CurrentModelError` carries the two launch-blocking causes, a failed catalog fetch and a selection absent from the fetched catalog, each reported to the operator instead of hidden behind a fabricated descriptor. - `current_model` now returns the fetched descriptor or a typed cause; an invalid selection id and the empty boot window still yield no model rather than an error. - `run_markdown_agent` turns a resolution cause into the chat launch error, so the run fails with the fetch named as cause instead of launching with unbound or mis-bound roles. - `spawn_agent_server` mounts the typed catalog route in the integration tests, since a mock without it now fails the launch instead of silently binding a fallback. - `FALLBACK_CONTEXT` and the fallback arm are deleted; no descriptor is ever fabricated. Design: removes swallowed-exception @ crates/workshop-sessions/src/agents/environment.rs::current_model deps: SessionHost,str,str Repairs: launch-time model resolution reports the fetch cause @ crates/workshop-sessions/src/agents/environment.rs::current_model - a failed catalog fetch silently bound a fabricated 8192-window fallback descriptor Repairs: launch-time model resolution reports an absent selection @ crates/workshop-sessions/src/agents/environment.rs::current_model - a selection missing from the fetched catalog silently bound the fallback descriptor Plan: vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md --- crates/workshop-server/tests/it/agents.rs | 10 +- .../src/agents/environment.rs | 146 +++++++++++++----- .../src/agents/supervisor/effects.rs | 10 +- ...-1-debt-removal-capabilities-follow-ups.md | 2 +- 4 files changed, 122 insertions(+), 46 deletions(-) diff --git a/crates/workshop-server/tests/it/agents.rs b/crates/workshop-server/tests/it/agents.rs index 7b9e29ef..a2a8505c 100644 --- a/crates/workshop-server/tests/it/agents.rs +++ b/crates/workshop-server/tests/it/agents.rs @@ -113,8 +113,8 @@ fn hanging_completions(started: &Notify) -> Response { /// Adds the typed `/v1/models` catalog a launch resolves the menu selection /// through. Every id these tests select carries a window that clears the -/// built-in chat's declared minimum, so a mock without this route would -/// bind the fallback descriptor and the role's minimum would refuse the run. +/// built-in chat's declared minimum; a mock without this route fails the +/// launch with the reported catalog-fetch cause. fn with_typed_catalog(router: Router) -> Router { router.route( "/v1/models", @@ -181,8 +181,10 @@ fn assert_replacement_request( /// holding `test-model`. Returns the server's base `ws://` URL, the /// tempdir keeping the state alive, and the shared state handle. async fn spawn_agent_server() -> (String, tempfile::TempDir, AppState) { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(echo_completions))).await; + let base_url = spawn_gateway(with_typed_catalog( + Router::new().route("/v1/chat/completions", post(echo_completions)), + )) + .await; spawn_agent_server_for_gateway(base_url).await } diff --git a/crates/workshop-sessions/src/agents/environment.rs b/crates/workshop-sessions/src/agents/environment.rs index eff47e0a..6155bd55 100644 --- a/crates/workshop-sessions/src/agents/environment.rs +++ b/crates/workshop-sessions/src/agents/environment.rs @@ -2,23 +2,14 @@ //! [`Environment`] every session run prepares against, and the launch-time //! resolution of the dropdown's current model into the per-run context. -use std::num::NonZeroU32; use std::sync::Arc; use promptforge_api::client::fetch_model_catalog; -use promptforge_api::{CapabilityRegistry, Environment, Web}; -use shared_promptforge_api::models::{ModelDescriptor, ModelId, ThinkingMode}; +use promptforge_api::{CapabilityRegistry, CompletionError, Environment, Web}; +use shared_promptforge_api::models::{ModelDescriptor, ModelId}; use super::SessionHost; -/// The context window a selection resolved without catalog metadata -/// records: a conservative default keeps the compactor precheck safe, -/// mirroring the raw-id binding's fallback. -const FALLBACK_CONTEXT: NonZeroU32 = match NonZeroU32::new(8192) { - Some(value) => value, - None => unreachable!(), -}; - /// Builds the sessions' shared environment for one gateway generation: /// model-free (the gateway's model list feeds the dropdown UI and never /// crosses this interface), carrying the first-party capabilities built @@ -48,26 +39,38 @@ pub fn session_environment(base_url: &str, api_key: &str) -> Option Some(Environment::new().registry(registry)) } +/// Why launch-time model resolution cannot bind a descriptor. Each cause +/// becomes the chat launch error, reported to the operator instead of +/// binding a fabricated fallback descriptor. +#[derive(Debug, thiserror::Error)] +pub(crate) enum CurrentModelError { + /// The gateway's model catalog could not be fetched. + #[error("the model catalog fetch failed: {0}")] + CatalogFetchFailed(#[source] CompletionError), + /// The selected id is absent from the fetched catalog. + #[error("the selected model `{0}` is absent from the fetched catalog")] + SelectionAbsent(String), +} + /// Resolves the dropdown's current model for one run's context. The /// selection is read at launch, so a selection change takes effect on the /// next run. A launch with no selection yet - the boot window before the /// menu's own auto-select settles - binds the retained catalog's first /// chat-capable model, the same fallback the menu applies. The typed /// descriptor comes from the gateway's model list through -/// [`fetch_model_catalog`]; when the fetch fails or the selection is -/// absent from it, a minimal descriptor under the fallback context window -/// keeps the run on the selected id, mirroring the raw-id binding's -/// fallback. +/// [`fetch_model_catalog`]. /// -/// Returns `None` only when neither a selection nor a catalog model +/// Returns `Ok(None)` only when neither a selection nor a catalog model /// exists, or the id is not representable; the prompt's declared roles -/// then stay unbound. +/// then stay unbound. A failed catalog fetch or a selection absent from +/// the fetched catalog is a reported [`CurrentModelError`], never a +/// fabricated fallback descriptor. pub(crate) async fn current_model( host: &SessionHost, base_url: &str, api_key: &str, -) -> Option { - let selected = host +) -> Result, CurrentModelError> { + let Some(selected) = host .menu() .latest() .and_then(|snapshot| snapshot.selected_model) @@ -79,33 +82,36 @@ pub(crate) async fn current_model( .get("id")? .as_str() .map(str::to_owned) - })?; + }) + else { + return Ok(None); + }; let id = match ModelId::gateway(&selected) { Ok(id) => id, Err(error) => { tracing::warn!(%error, "the selected model id is invalid"); - return None; + return Ok(None); } }; let root = format!("{}/v1", base_url.trim_end_matches('/')); - let fetched = match fetch_model_catalog(&root, api_key).await { - Ok(catalog) => catalog.get(&id).cloned(), - Err(error) => { - tracing::warn!(%error, "the model catalog fetch failed; the selection binds under the fallback descriptor"); - None - } - }; - Some(fetched.unwrap_or_else(|| { - tracing::debug!(model = %selected, "binding the selection under the fallback descriptor"); - ModelDescriptor::new(id, "", FALLBACK_CONTEXT, ThinkingMode::Never) - })) + let catalog = fetch_model_catalog(&root, api_key) + .await + .map_err(CurrentModelError::CatalogFetchFailed)?; + let descriptor = catalog + .get(&id) + .cloned() + .ok_or_else(|| CurrentModelError::SelectionAbsent(selected))?; + Ok(Some(descriptor)) } #[cfg(test)] mod tests { + use std::num::NonZeroU32; use std::sync::Mutex; use std::time::Duration; + use shared_promptforge_api::models::ThinkingMode; + use workshop_gateway::GatewayBinding; use workshop_menu::{CatalogBus, MenuBus}; use workshop_registry::Registry; @@ -169,33 +175,61 @@ mod tests { let host = host_with_catalog(true); let model = current_model(&host, &base_url, "k") .await + .expect("the fetch succeeds") .expect("the selection resolves"); assert_eq!(model.id().name(), "test-model"); assert_eq!( model.context(), NonZeroU32::new(4096).expect("4096 is non-zero"), - "the fetched descriptor wins over the fallback" + "the fetched descriptor binds" ); assert_eq!(model.thinking(), ThinkingMode::Switchable); } #[tokio::test] - async fn a_failed_catalog_fetch_binds_the_fallback_descriptor() { + async fn a_failed_catalog_fetch_reports_the_fetch_as_the_launch_cause() { // Port 1 refuses the connection: the fetch fails fast. let host = host_with_catalog(true); - let model = current_model(&host, "http://127.0.0.1:1", "k") + let error = current_model(&host, "http://127.0.0.1:1", "k") .await - .expect("the fallback keeps the selected id"); - assert_eq!(model.id().name(), "test-model"); - assert_eq!(model.context(), FALLBACK_CONTEXT); - assert_eq!(model.thinking(), ThinkingMode::Never); + .expect_err("a failed fetch is a reported cause, never a fallback descriptor"); + assert!( + matches!(error, CurrentModelError::CatalogFetchFailed(_)), + "the cause is the failed fetch: {error}" + ); + assert!( + error.to_string().contains("catalog fetch failed"), + "the reported launch error names the fetch as cause: {error}" + ); + } + + #[tokio::test] + async fn a_selection_absent_from_the_fetched_catalog_is_reported() { + let base_url = spawn_models_gateway().await; + let catalog = CatalogBus::new(); + catalog.publish(vec![ + serde_json::json!({ "id": "elsewhere-model", "object": "model" }), + ]); + let menu = MenuBus::new(catalog.clone(), None); + menu.set_selected("elsewhere-model") + .expect("the id is in the catalog"); + let host = SessionHost::new(Registry::new(), ReconnectBackoff::new(), menu, catalog); + let error = current_model(&host, &base_url, "k") + .await + .expect_err("a selection missing from the fetched catalog is a reported cause"); + assert!( + matches!(error, CurrentModelError::SelectionAbsent(_)), + "the cause is the absent selection: {error}" + ); } #[tokio::test] async fn a_launch_without_a_selection_binds_the_first_catalog_model() { + let base_url = spawn_models_gateway().await; let host = host_with_catalog(false); - let model = current_model(&host, "http://127.0.0.1:1", "k") + let model = current_model(&host, &base_url, "k") .await + .expect("the fetch succeeds") .expect("the catalog's first model stands in"); assert_eq!(model.id().name(), "test-model"); } @@ -208,6 +242,7 @@ mod tests { assert!( current_model(&host, "http://127.0.0.1:1", "k") .await + .expect("no selection is no model, not a reported cause") .is_none() ); } @@ -421,4 +456,35 @@ mod tests { assert!(sessions.close(&session.id), "the session ends"); } + + #[tokio::test] + async fn a_failed_catalog_fetch_fails_the_chat_launch_naming_the_fetch_as_cause() { + // Port 1 refuses the connection: the launch-time catalog fetch + // fails fast, and the run must fail with the launch error rather + // than launch with unbound roles. + let host = host_with_catalog(true); + let dir = tempfile::TempDir::new().expect("tempdir"); + let sessions = AgentSessions::new( + dir.path().join("missing-agents"), + dir.path().join("sessions"), + GatewayBinding::new("http://127.0.0.1:1", "test-key").expect("the binding builds"), + host, + ); + let session = sessions.launch("chat").expect("the built-in chat launches"); + // Subscribed before the first yield, so the failure frame the + // supervisor task is about to send cannot be missed. + let mut errors = session.subscribe_errors(); + let message = tokio::time::timeout(Duration::from_secs(10), errors.recv()) + .await + .expect("the failed run reports an error frame") + .expect("the error channel is live"); + assert!( + message.contains("the chat cannot launch"), + "the run failed with the launch error: {message}" + ); + assert!( + message.contains("catalog fetch failed"), + "the launch error names the fetch as cause: {message}" + ); + } } diff --git a/crates/workshop-sessions/src/agents/supervisor/effects.rs b/crates/workshop-sessions/src/agents/supervisor/effects.rs index e6722245..4036a106 100644 --- a/crates/workshop-sessions/src/agents/supervisor/effects.rs +++ b/crates/workshop-sessions/src/agents/supervisor/effects.rs @@ -157,7 +157,15 @@ async fn run_markdown_agent( Arc::clone(&session.waits), session.input_frames.clone(), )); - let model = current_model(&host, gateway.base_url(), gateway.api_key()).await; + let model = match current_model(&host, gateway.base_url(), gateway.api_key()).await { + Ok(model) => model, + Err(cause) => { + return Err(AgentRunError::Failed { + message: format!("the chat cannot launch: {cause}"), + source: Some(Box::new(cause)), + }); + } + }; let mut ctx = RunContext::new(session.id.clone()) .observer(observer) .client(client) diff --git a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md index c46897c1..6cf7653d 100644 --- a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md +++ b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md @@ -171,7 +171,7 @@ Components in dependency order: -### Step 3: delete the 8192 fallback model descriptor +### Step 3: delete the 8192 fallback model descriptor [completed] - Component: fallback-descriptor-removal - Piece: catalog-failure-reporting (single piece, joint construction: the `current_model` signature change, the fallback deletion, and the caller's error reporting compile together) From 08fcd077af322f2111b3409d328ccbbf7a871531 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 03:49:25 -0700 Subject: [PATCH 26/30] Convert bound-tool dispatch failures into tool results A bound tool's own failure no longer ends the model run. The error message becomes the call's result record, wrapped as untrusted content, so the model reads the failure and the loop continues to its terminal reply. Cancellation, quota, and every other dispatch failure still abort the run. The clean-exit counter now counts every answered call, error results included. - `run_models_loop` Bound arm: an `Err(Error::Tool { message, .. })` outcome becomes `nonce.wrap(&message)`, reported through `observer.on_tool_result` with trusted false; every other error still returns. - `answered_tool_calls`: renamed from `successful_tool_calls` and incremented for any call that received a result record, so the empty-reply clean-exit check still fires after error results. - `a_failing_tool_becomes_an_untrusted_error_result_and_the_loop_continues`: pins the result record carrying the tool's error message, nonce-wrapped as untrusted, with TOOL_CALL_FAILED firing and the loop running a second completed turn. - `a_counts_failure_still_aborts_the_loop` and `repeated_calls_to_a_failing_tool_exit_at_the_iteration_cap`: new tests pinning that a quota-layer failure still aborts and that a model repeating a failing call exits at the iteration cap. - `a_failing_tool_is_reported_before_the_error_propagates`: the source-chain assertion on the propagated tool error is gone; the error no longer propagates, so there is no chain to preserve. Deferred: a test that cancellation still aborts mid-dispatch Deferred: a test that a local tool handler error still aborts the loop Repairs: a bound tool's own failure must reach the model as an untrusted error result @ crates/promptforge-api/src/execute/tool_loop.rs::run_models_loop - a tool backend error aborted the whole run instead of becoming the call's result record Plan: vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md --- .../src/execute/tests/debug_and_counts.rs | 25 ++- .../src/execute/tests/tool_loop.rs | 148 +++++++++++++++--- .../promptforge-api/src/execute/tool_loop.rs | 80 ++++++---- ...-1-debt-removal-capabilities-follow-ups.md | 2 +- 4 files changed, 187 insertions(+), 68 deletions(-) diff --git a/crates/promptforge-api/src/execute/tests/debug_and_counts.rs b/crates/promptforge-api/src/execute/tests/debug_and_counts.rs index 8de2a9e1..ab7347ee 100644 --- a/crates/promptforge-api/src/execute/tests/debug_and_counts.rs +++ b/crates/promptforge-api/src/execute/tests/debug_and_counts.rs @@ -229,11 +229,13 @@ async fn tool_calls_count_increments_on_successful_dispatch() { async fn tool_calls_count_increments_even_when_tool_errors() { // TESTS-002: drive a real `FailingTool` through `run_tool_loop` and prove the // counter records exactly one call even though the tool errors (the count is - // incremented before dispatch), and that the tool's backend error still ends - // the loop. The old version poked `ToolCallCounts` directly and dispatched no - // tool at all. - let gateway = - ScriptedGateway::start(vec![resp_tool_call("call_x", "echo", "{\"value\":\"x\"}")]).await; + // incremented before dispatch). The tool's own failure is now the call's + // error result, so the loop continues to the terminal reply. + let gateway = ScriptedGateway::start(vec![ + resp_tool_call("call_x", "echo", "{\"value\":\"x\"}"), + resp_text("final answer"), + ]) + .await; let addr = gateway.addr(); let client = gateway_client(addr); @@ -249,7 +251,7 @@ async fn tool_calls_count_increments_even_when_tool_errors() { // The gateway always calls the tool wired as "echo". let counts = ToolCallCounts::new(["echo".to_string()]); - let err = run_tool_loop( + let (out, _) = run_tool_loop( &client, &schemas, &dispatch, @@ -265,15 +267,8 @@ async fn tool_calls_count_increments_even_when_tool_errors() { None, ) .await - .expect_err("a tool whose call fails must fail the loop"); - - match &err { - Error::Tool { message, .. } => assert!( - message.contains("the tool's own backend failed"), - "the tool's own backend error must propagate: {message}" - ), - other => panic!("expected the tool's own backend error, got {other:?}"), - } + .expect("a tool's own failure becomes the call's result, not the loop's"); + assert_eq!(out, "final answer"); assert_eq!( counts.get("echo").expect("echo is a tracked alias"), diff --git a/crates/promptforge-api/src/execute/tests/tool_loop.rs b/crates/promptforge-api/src/execute/tests/tool_loop.rs index c6bccc94..36215158 100644 --- a/crates/promptforge-api/src/execute/tests/tool_loop.rs +++ b/crates/promptforge-api/src/execute/tests/tool_loop.rs @@ -280,12 +280,15 @@ async fn tool_loop_errors_on_unknown_tool() { } #[tokio::test] -async fn a_failing_tool_is_reported_before_the_error_propagates() { - // The dispatch is split from the `?` precisely so a tool that fails is - // still reported: the recorder must see `ToolCalled { ok: false }` and - // the tool's own error must still end the loop. - let gateway = - ScriptedGateway::start(vec![resp_tool_call("call_x", "echo", "{\"value\":\"x\"}")]).await; +async fn a_failing_tool_becomes_an_untrusted_error_result_and_the_loop_continues() { + // A bound tool's own failure is the call's result record - the ToolError + // message, nonce-wrapped as untrusted - with TOOL_CALL_FAILED firing + // alongside it, and the loop continues to the terminal reply. + let gateway = ScriptedGateway::start(vec![ + resp_tool_call("call_x", "echo", "{\"value\":\"x\"}"), + resp_text("final answer"), + ]) + .await; let addr = gateway.addr(); let client = gateway_client(addr); @@ -298,7 +301,7 @@ async fn a_failing_tool_is_reported_before_the_error_propagates() { let turns = AtomicU32::new(0); let options = test_completion_options(); let nonce = GuardNonce::fresh(); - let err = run_tool_loop( + let (out, _) = run_tool_loop( &client, &schemas, &dispatch, @@ -314,26 +317,23 @@ async fn a_failing_tool_is_reported_before_the_error_propagates() { None, ) .await - .expect_err("a tool whose call fails must fail the loop"); - match &err { - Error::Tool { message, .. } => assert!( - message.contains("the tool's own backend failed"), - "the tool's own error propagates: {message}" - ), - other => panic!("expected the tool's own error, got {other:?}"), - } - // The tool's error (and its own inner cause) must be preserved as the - // error's source chain, not discarded when bridged into the run error. - let source = std::error::Error::source(&err).expect("tool error is kept as the source"); - let chain = std::iter::successors(Some(source), |error| error.source()) - .map(std::string::ToString::to_string) - .collect::>() - .join(" -> "); + .expect("a tool's own failure becomes the call's result, not the loop's"); + assert_eq!(out, "final answer", "the loop continues to the terminal reply"); + + // The result record carries the tool's error message, guard-wrapped as + // untrusted content. + let content = last_tool_turn_content(&gateway.requests()); + assert!( + content.contains("the tool's own backend failed"), + "the result record must carry the tool's error message, got: {content}" + ); assert!( - chain.contains("upstream socket reset"), - "the tool's inner cause must survive in the source chain, got: {chain}" + content.contains(" = Arc::new(EchoTool); + let tools: Vec> = vec![echo]; + let schemas = schemas_for(&tools); + let dispatch = dispatch_for(&tools); + + let turns = AtomicU32::new(0); + let options = test_completion_options(); + let nonce = GuardNonce::fresh(); + // Seeded with a different alias: the increment for "echo" fails. + let counts = ToolCallCounts::new(["other".to_string()]); + let err = run_tool_loop( + &client, + &schemas, + &dispatch, + "ask the model".to_string(), + DEFAULT_MAX_TOOL_ITERATIONS, + &NullObserver::default(), + "Only", + &turns, + &options, + &nonce, + Some(&counts), + None, + None, + ) + .await + .expect_err("a counts failure must abort the loop"); + assert!( + err.to_string().contains("was not pre-seeded"), + "the counts failure propagates unchanged, got: {err}" + ); + assert_eq!( + gateway.call_count(), + 1, + "the loop aborted on the first dispatch" + ); +} + +#[tokio::test] +async fn repeated_calls_to_a_failing_tool_exit_at_the_iteration_cap() { + // Every round's failing call becomes an error result, so a model that + // keeps calling the failing tool never converges: the loop exits at + // exactly `max_tool_iterations`. + let cap = 3; + let gateway = + ScriptedGateway::start(vec![resp_tool_call("call_x", "echo", "{\"value\":\"x\"}")]).await; + let addr = gateway.addr(); + let client = gateway_client(addr); + + let failing: Arc = Arc::new(FailingTool); + let tools: Vec> = vec![failing]; + let schemas = schemas_for(&tools); + let dispatch = dispatch_for(&tools); + + let turns = AtomicU32::new(0); + let options = test_completion_options(); + let nonce = GuardNonce::fresh(); + let err = run_tool_loop( + &client, + &schemas, + &dispatch, + "ask the model".to_string(), + cap, + &NullObserver::default(), + "Only", + &turns, + &options, + &nonce, + None, + None, + None, + ) + .await + .expect_err("a never-converging model should exhaust the loop"); + assert!(matches!(err, Error::ToolLoopExhausted)); + assert_eq!( + gateway.call_count(), + cap, + "each round answers the failing call and loops, exiting at the cap" + ); +} + #[tokio::test] async fn a_failing_model_turn_is_reported_before_the_error_propagates() { let gateway = ScriptedGateway::start(vec![resp_status(500, "private backend response")]).await; diff --git a/crates/promptforge-api/src/execute/tool_loop.rs b/crates/promptforge-api/src/execute/tool_loop.rs index 782c6804..fff1590b 100644 --- a/crates/promptforge-api/src/execute/tool_loop.rs +++ b/crates/promptforge-api/src/execute/tool_loop.rs @@ -8,8 +8,10 @@ //! streaming gateway completion under cancellation, and either appends the //! terminal assistant text and returns, or dispatches the requested //! tool-call batch and appends the exchange - the assistant record and its -//! correlated tool results together, only after every dispatch in the batch -//! succeeded - before looping. Overflow on the precheck or at the provider +//! correlated tool results together, once every dispatch in the batch has +//! its result - before looping. A bound tool's own failure is the call's +//! result record (the error message, nonce-wrapped as untrusted), not the +//! loop's. Overflow on the precheck or at the provider //! invokes the selected compactor (the omitted-compactor default is //! `compactors.fail`, which always raises typed context exhaustion). //! @@ -113,16 +115,19 @@ fn call_metrics(completion: &Completion) -> Option { /// The conversation arrives projected from the author's validated records; /// the loop appends its own wire messages as rounds complete. Each append /// to the author's list lands as its round completes: a tool-call exchange -/// appends atomically once every dispatch in the batch succeeded, and the -/// terminal assistant text is the final record. Returns `()` on success - -/// the Lua shim resumes nil. +/// appends atomically once every dispatch in the batch has its result +/// record, and the terminal assistant text is the final record. Returns +/// `()` on success - the Lua shim resumes nil. /// /// # Errors /// Returns an out-of-scope tool error if the model calls an alias absent from /// `dispatch`, [`Error::ToolLoopExhausted`] if the cap is hit /// without a text reply, [`Error::Interrupted`] -/// when the run is cancelled, any transport/backend error from a model call -/// or a tool's own failure, or the append sink's own error. Returns the +/// when the run is cancelled, any transport/backend error from a model call, +/// a local tool handler's failure, a bound call's cancellation or counts +/// failure, or the append sink's own error. A bound tool's own failure is +/// not the loop's: it becomes the call's result record and the run +/// continues. Returns the /// selected compactor's error - typed [`Error::ContextExhausted`] from the /// `compactors.fail` default - when the pre-dispatch precheck or the /// provider reports a context-window overflow. Returns [`Error::Internal`] @@ -160,9 +165,9 @@ pub(crate) async fn run_models_loop( Some(schemas) }; - // Completed dispatches only: a tool handler failure aborts the loop, so - // reaching the next round already proves the earlier calls succeeded. - let mut successful_tool_calls: usize = 0; + // Answered dispatches: any call that received a result record, error + // included, counts toward the clean-exit check below. + let mut answered_tool_calls: usize = 0; for _ in 0..max_tool_iterations { // The pre-dispatch precheck: estimate the request against the @@ -207,7 +212,7 @@ pub(crate) async fn run_models_loop( // failure. if let Err(Error::EmptyModelReply { finish_reason, .. }) = &completion && finish_reason.as_deref() == Some("stop") - && successful_tool_calls > 0 + && answered_tool_calls > 0 { // The accepted exit is still a completed turn: count it and report // it so observers and turn totals match a text-reply exit. No @@ -373,28 +378,49 @@ pub(crate) async fn run_models_loop( None, ) .await - .map_err(Error::from)?; - observer.on_tool_result( - execution, - section, - 0, - 0, - turn, - &call.id, - &call.name, - outcome.content(), - outcome.trusted(), - ); - outcome.into_content() + .map_err(Error::from); + match outcome { + Ok(outcome) => { + observer.on_tool_result( + execution, + section, + 0, + 0, + turn, + &call.id, + &call.name, + outcome.content(), + outcome.trusted(), + ); + outcome.into_content() + } + // A tool's own failure is the call's result + // record - the error message, nonce-wrapped as + // untrusted - so the model reads the failure + // and the run continues; `dispatch_tool` has + // already fired TOOL_CALL_FAILED. Cancellation, + // the counts increment, and every other + // dispatch failure still abort the loop. + Err(Error::Tool { message, .. }) => { + let content = nonce.wrap(&message); + observer.on_tool_result( + execution, section, 0, 0, turn, &call.id, &call.name, + &content, false, + ); + content + } + Err(error) => return Err(error), + } } }; - successful_tool_calls += 1; + answered_tool_calls += 1; results.push((call.id.clone(), result)); } // The exchange appends atomically: reaching here means every - // dispatch in the batch succeeded, so the author's list never - // holds an assistant call its results did not answer. + // dispatch in the batch has its result record, so the author's + // list never holds an assistant call its results did not + // answer. // // Echo in the OpenAI wire shape: the assistant's tool-call turn // followed by one `role=tool` message per result. The assistant diff --git a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md index 6cf7653d..02e5cbd9 100644 --- a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md +++ b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md @@ -183,7 +183,7 @@ Components in dependency order: -### Step 4: convert bound-tool dispatch failures into tool results +### Step 4: convert bound-tool dispatch failures into tool results [completed] - Component: tool-errors-as-results - Piece: bound-arm-error-as-result (sequential before the guide piece: behavior lands first, docs describe landed behavior) From 9a82d42cbac83fdf87645a4d74804a19863d1675 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 03:54:21 -0700 Subject: [PATCH 27/30] Document bound tool failures as untrusted tool results The language guide now documents that a bound tool's own failure arrives as the call's tool result wrapped as untrusted input, so the model reads the failure and the run continues, while cancellation and every other dispatch failure still abort the loop. It also states that a bound tool's failure text is wrapped as untrusted whatever the tool's trust marking, so an error message from the outside world never reaches the model as trusted prose. A local variable in the loop's tool-error arm was renamed for clarity and one test assertion reformatted; no behavior changed. - `guide/src/language/07-tools.md` - documents that a bound tool's own failure becomes the call's untrusted-wrapped tool result and the run continues; cancellation and every other dispatch failure still abort the loop, and a local tool's handler error still fails the run. - `guide/promptforge-language-guide.md` - regenerated assembled guide carrying the same two passages. Plan: vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md --- crates/promptforge-api/src/execute/tests/tool_loop.rs | 5 ++++- crates/promptforge-api/src/execute/tool_loop.rs | 6 +++--- guide/promptforge-language-guide.md | 4 ++-- guide/src/language/07-tools.md | 4 ++-- vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/promptforge-api/src/execute/tests/tool_loop.rs b/crates/promptforge-api/src/execute/tests/tool_loop.rs index 36215158..bb5ffc7b 100644 --- a/crates/promptforge-api/src/execute/tests/tool_loop.rs +++ b/crates/promptforge-api/src/execute/tests/tool_loop.rs @@ -318,7 +318,10 @@ async fn a_failing_tool_becomes_an_untrusted_error_result_and_the_loop_continues ) .await .expect("a tool's own failure becomes the call's result, not the loop's"); - assert_eq!(out, "final answer", "the loop continues to the terminal reply"); + assert_eq!( + out, "final answer", + "the loop continues to the terminal reply" + ); // The result record carries the tool's error message, guard-wrapped as // untrusted content. diff --git a/crates/promptforge-api/src/execute/tool_loop.rs b/crates/promptforge-api/src/execute/tool_loop.rs index fff1590b..543fbcdc 100644 --- a/crates/promptforge-api/src/execute/tool_loop.rs +++ b/crates/promptforge-api/src/execute/tool_loop.rs @@ -402,12 +402,12 @@ pub(crate) async fn run_models_loop( // the counts increment, and every other // dispatch failure still abort the loop. Err(Error::Tool { message, .. }) => { - let content = nonce.wrap(&message); + let wrapped = nonce.wrap(&message); observer.on_tool_result( execution, section, 0, 0, turn, &call.id, &call.name, - &content, false, + &wrapped, false, ); - content + wrapped } Err(error) => return Err(error), } diff --git a/guide/promptforge-language-guide.md b/guide/promptforge-language-guide.md index 5a4d7465..0a385659 100644 --- a/guide/promptforge-language-guide.md +++ b/guide/promptforge-language-guide.md @@ -523,13 +523,13 @@ The counter `tools.calls[alias]` reads how many times the model has called a too ## Trusted and untrusted output -Output from a tool that marks its result untrusted is wrapped in a preface and nonce-tagged `` markers before the model sees it. Trusted tool output appends verbatim, and structured JSON output from a trusted tool resumes into Lua as a table. +Output from a tool that marks its result untrusted is wrapped in a preface and nonce-tagged `` markers before the model sees it. Trusted tool output appends verbatim, and structured JSON output from a trusted tool resumes into Lua as a table. A bound tool's failure text is wrapped as untrusted whatever the tool's trust marking, so an error message from the outside world never reaches the model as trusted prose. ## Validation and edge cases Two semantic near-duplicate tools in one model-visible scope fail validation, with an error naming both aliases, both identities, and the similarity score. If you genuinely need both, isolate them in separate sections with per-section `tools.add`. -An empty final reply from the model fails the loop unless a tool call preceded it and the finish reason is `stop`. A `length` finish reason returns the partial text and reports truncation. And a tool handler failure aborts the tool loop and fails the run with the tool's own error, preserving the underlying cause in the error chain. +An empty final reply from the model fails the loop unless a tool call preceded it and the finish reason is `stop`. A `length` finish reason returns the partial text and reports truncation. A bound tool's own failure does not abort the loop: the error message arrives as the call's tool result, wrapped as untrusted input, so the model reads the failure and the run continues. Cancellation and every other dispatch failure still abort the loop, and a local tool's handler error still fails the run. ## Migrating from tools.bind diff --git a/guide/src/language/07-tools.md b/guide/src/language/07-tools.md index 2416ab2e..d98e6dac 100644 --- a/guide/src/language/07-tools.md +++ b/guide/src/language/07-tools.md @@ -90,13 +90,13 @@ The counter `tools.calls[alias]` reads how many times the model has called a too ## Trusted and untrusted output -Output from a tool that marks its result untrusted is wrapped in a preface and nonce-tagged `` markers before the model sees it. Trusted tool output appends verbatim, and structured JSON output from a trusted tool resumes into Lua as a table. +Output from a tool that marks its result untrusted is wrapped in a preface and nonce-tagged `` markers before the model sees it. Trusted tool output appends verbatim, and structured JSON output from a trusted tool resumes into Lua as a table. A bound tool's failure text is wrapped as untrusted whatever the tool's trust marking, so an error message from the outside world never reaches the model as trusted prose. ## Validation and edge cases Two semantic near-duplicate tools in one model-visible scope fail validation, with an error naming both aliases, both identities, and the similarity score. If you genuinely need both, isolate them in separate sections with per-section `tools.add`. -An empty final reply from the model fails the loop unless a tool call preceded it and the finish reason is `stop`. A `length` finish reason returns the partial text and reports truncation. And a tool handler failure aborts the tool loop and fails the run with the tool's own error, preserving the underlying cause in the error chain. +An empty final reply from the model fails the loop unless a tool call preceded it and the finish reason is `stop`. A `length` finish reason returns the partial text and reports truncation. A bound tool's own failure does not abort the loop: the error message arrives as the call's tool result, wrapped as untrusted input, so the model reads the failure and the run continues. Cancellation and every other dispatch failure still abort the loop, and a local tool's handler error still fails the run. ## Migrating from tools.bind diff --git a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md index 02e5cbd9..bc1a6f81 100644 --- a/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md +++ b/vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md @@ -195,7 +195,7 @@ Components in dependency order: -### Step 5: document tool failures as tool results in the guide +### Step 5: document tool failures as tool results in the guide [completed] - Component: tool-errors-as-results - Piece: guide-documentation (sequential after the bound-arm piece) From 72bec4745fd671b38ef22a17203f75990363f021 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 04:13:38 -0700 Subject: [PATCH 28/30] Close plan: Debt removal: capabilities follow-ups Plan: vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md --- vibe/ACTIVE | 1 - 1 file changed, 1 deletion(-) delete mode 100644 vibe/ACTIVE diff --git a/vibe/ACTIVE b/vibe/ACTIVE deleted file mode 100644 index 98944762..00000000 --- a/vibe/ACTIVE +++ /dev/null @@ -1 +0,0 @@ -vibe/2026-09-14-1-debt-removal-capabilities-follow-ups.md From 1537ecf2ad1a97abe2cd6e1c398d9b9fe7daa6df Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 06:16:58 -0700 Subject: [PATCH 29/30] Move vibe session logs into monthly subdirectories Group the flat `vibe/` log directory by month. Move each session log into `vibe/2026-07/`, `vibe/2026-08/`, or `vibe/2026-09/` based on the date in its file name. - 45 of the 108 files also renormalize from CRLF to LF under the `* text=auto eol=lf` rule in `.gitattributes`; `git diff --ignore-cr-at-eol` shows no text changes. --- vibe/{ => 2026-07}/2026-07-29-1-gateway-v0.md | 0 .../2026-07-29-2-lua-args-substitution.md | 0 .../2026-07-29-3-brave-web-search-tools.md | 0 .../2026-07-29-4-webfetch-crate-extraction.md | 0 ...2026-07-29-5-multi-turn-research-prompt.md | 0 .../2026-07-29-6-per-section-tool-scoping.md | 0 ...026-07-29-7-guard-wrap-untrusted-output.md | 0 .../2026-07-31-1-orchestrator-only.md | 0 .../{ => 2026-08}/2026-08-02-1-tool-picker.md | 432 +++++++++--------- vibe/{ => 2026-08}/2026-08-02-2-mcp-server.md | 0 .../2026-08-03-1-mcp-server-correction.md | 0 ...6-08-04-1-recover-core-design-rationale.md | 0 .../2026-08-05-1-section-lua-lifecycle.md | 0 .../2026-08-06-1-prompt-fixtures-logging.md | 0 vibe/{ => 2026-08}/2026-08-07-1-dev-loop.md | 0 .../2026-08-07-2-store-type-rename.md | 0 .../2026-08-07-3-models-debug-cluster.md | 0 ...2026-08-07-4-completion-normalize-layer.md | 0 .../2026-08-07-5-store-and-fanout.md | 0 .../2026-08-08-1-models-always.md | 0 ...026-08-08-10-fanout-gateway-concurrency.md | 0 ...26-08-08-11-briefer-evidence-thickening.md | 0 ...6-08-08-12-supervise-local-llama-server.md | 0 .../2026-08-08-2-webfetch-soft-errors.md | 0 .../2026-08-08-3-gateway-local-inference.md | 0 .../2026-08-08-4-add-sys-model.md | 0 .../2026-08-08-5-extract-dev-crate.md | 0 .../2026-08-08-6-local-toml-bigger-model.md | 0 .../2026-08-08-7-gateway-download-progress.md | 0 .../2026-08-08-8-tool-dialect-plugins.md | 2 +- ...2026-08-08-9-write-through-store-traces.md | 0 ...2026-08-09-1-gateway-web-search-upgrade.md | 0 .../2026-08-09-2-promptforge-user-guide.md | 0 .../2026-08-09-3-readme-and-ci-overhaul.md | 0 .../2026-08-09-4-store-real-plus-virtual.md | 0 .../2026-08-09-5-h1-once-no-replay.md | 180 ++++---- .../2026-08-10-1-crate-review.md | 0 .../2026-08-11-1-dokuman-all-crates.md | 0 .../2026-08-11-2-make-user-guide-crate.md | 0 .../2026-08-12-1-mdbook-guide.md | 0 .../2026-08-14-1-file-backed-store.md | 0 .../2026-08-15-1-store-input-files.md | 0 .../2026-08-15-2-quickref-file.md | 0 .../2026-08-15-3-gateway-dotenv-support.md | 0 .../2026-08-15-4-h1-only-prompt-support.md | 0 .../2026-08-16-1-fanout-scope-refactor.md | 0 .../{ => 2026-08}/2026-08-18-1-tools-local.md | 0 .../2026-08-18-2-empty-turn-and-mcp-env.md | 0 ...026-08-18-3-untrusted-global-vm-reorder.md | 0 .../2026-08-18-4-fanout-collection-items.md | 0 ...2026-08-19-1-collapse-fanout-arm-engine.md | 0 .../2026-08-19-2-promptforge-refactor.md | 0 .../2026-08-20-1-fix-core-review-findings.md | 0 .../2026-08-22-1-runcontext-seed.md | 0 .../2026-08-23-1-dominion-refactor.md | 2 +- .../2026-08-23-2-gateway-phases-4-5.md | 4 +- .../2026-08-24-1-stage-1-the-window.md | 4 +- .../2026-08-24-2-autogen-config-first-run.md | 2 +- .../2026-08-24-3-voice-ux-fixes.md | 6 +- .../2026-08-24-4-progressive-transcription.md | 4 +- .../2026-08-24-5-stop-recording-on-send.md | 2 +- .../2026-08-24-6-ci-and-hygiene-fixes.md | 0 ...26-08-25-1-rename-workbench-to-workshop.md | 4 +- .../2026-08-26-1-remove-voice-status-line.md | 2 +- .../2026-08-26-2-model-turn-actions.md | 4 +- .../2026-08-26-3-workshop-regression-fixes.md | 0 .../2026-08-26-4-workshop-idiom-refactor.md | 2 +- .../2026-08-27-1-refresh-tree-on-drop.md | 2 +- .../2026-08-27-2-workshop-server-refactor.md | 4 +- .../2026-08-27-3-server-driven-menu-state.md | 4 +- .../2026-08-27-4-merged-gateway-workshop.md | 6 +- .../2026-08-28-1-fix-open-vibe-findings.md | 162 +++---- ...026-08-28-2-coroutine-protocol-executor.md | 2 +- ...26-08-28-3-digest-marker-child-priority.md | 4 +- .../2026-08-28-4-cuda-llama-provisioning.md | 2 +- ...2026-08-29-1-crate-extraction-execution.md | 2 +- ...26-08-29-2-rename-ws-crates-to-workshop.md | 2 +- .../2026-08-29-3-chat-ws-decomposition.md | 4 +- ...6-08-29-4-progress-architecture-rollout.md | 4 +- .../2026-08-29-5-gateway-config-spa.md | 2 +- ...8-30-1-chat-templates-injection-defense.md | 4 +- .../2026-08-31-1-fix-hf-proxy-consistency.md | 2 +- ...26-08-31-2-rebuild-mdbook-from-includes.md | 2 +- .../2026-08-31-3-republish-crates.md | 0 ...26-08-31-4-core-support-api-refinements.md | 4 +- ...6-08-31-5-desktop-shell-review-followup.md | 2 +- ...26-08-31-6-tauri-migration-for-workshop.md | 2 +- .../2026-08-31-7-interactive-webhook-tool.md | 4 +- .../2026-09-01-1-build-simplification.md | 4 +- .../2026-09-01-2-nightly-installer-builds.md | 2 +- ...026-09-02-1-workshop-agent-window-clone.md | 2 +- .../2026-09-02-2-workshop-cursor-parity.md | 2 +- .../2026-09-02-3-whisper-shared-lib.md | 4 +- ...026-09-02-4-evergreen-links-and-version.md | 4 +- .../2026-09-02-5-crate-taxonomy-rename.md | 2 +- .../2026-09-02-6-product-user-guides.md | 2 +- ...026-09-03-1-enhance-tts-endpoint-report.md | 0 ...026-09-03-2-enhance-image-endpoint-spec.md | 0 ...6-09-03-3-gateway-sidecar-decomposition.md | 4 +- .../2026-09-04-1-async-boot-and-progress.md | 2 +- .../2026-09-04-2-apply-as-queue-command.md | 6 +- ...9-04-3-unlock-inference-during-switches.md | 4 +- .../2026-09-05-1-gateway-logging-cli.md | 0 .../2026-09-05-2-generic-realtime-stt.md | 0 .../2026-09-07-1-promptforge-debt.md | 0 .../2026-09-07-2-gateway-tts-phase-1.md | 0 ...026-09-08-1-remove-unsupported-ratchets.md | 0 .../2026-09-09-2-async-stt-boot-reset.md | 0 108 files changed, 454 insertions(+), 454 deletions(-) rename vibe/{ => 2026-07}/2026-07-29-1-gateway-v0.md (100%) rename vibe/{ => 2026-07}/2026-07-29-2-lua-args-substitution.md (100%) rename vibe/{ => 2026-07}/2026-07-29-3-brave-web-search-tools.md (100%) rename vibe/{ => 2026-07}/2026-07-29-4-webfetch-crate-extraction.md (100%) rename vibe/{ => 2026-07}/2026-07-29-5-multi-turn-research-prompt.md (100%) rename vibe/{ => 2026-07}/2026-07-29-6-per-section-tool-scoping.md (100%) rename vibe/{ => 2026-07}/2026-07-29-7-guard-wrap-untrusted-output.md (100%) rename vibe/{ => 2026-07}/2026-07-31-1-orchestrator-only.md (100%) rename vibe/{ => 2026-08}/2026-08-02-1-tool-picker.md (99%) rename vibe/{ => 2026-08}/2026-08-02-2-mcp-server.md (100%) rename vibe/{ => 2026-08}/2026-08-03-1-mcp-server-correction.md (100%) rename vibe/{ => 2026-08}/2026-08-04-1-recover-core-design-rationale.md (100%) rename vibe/{ => 2026-08}/2026-08-05-1-section-lua-lifecycle.md (100%) rename vibe/{ => 2026-08}/2026-08-06-1-prompt-fixtures-logging.md (100%) rename vibe/{ => 2026-08}/2026-08-07-1-dev-loop.md (100%) rename vibe/{ => 2026-08}/2026-08-07-2-store-type-rename.md (100%) rename vibe/{ => 2026-08}/2026-08-07-3-models-debug-cluster.md (100%) rename vibe/{ => 2026-08}/2026-08-07-4-completion-normalize-layer.md (100%) rename vibe/{ => 2026-08}/2026-08-07-5-store-and-fanout.md (100%) rename vibe/{ => 2026-08}/2026-08-08-1-models-always.md (100%) rename vibe/{ => 2026-08}/2026-08-08-10-fanout-gateway-concurrency.md (100%) rename vibe/{ => 2026-08}/2026-08-08-11-briefer-evidence-thickening.md (100%) rename vibe/{ => 2026-08}/2026-08-08-12-supervise-local-llama-server.md (100%) rename vibe/{ => 2026-08}/2026-08-08-2-webfetch-soft-errors.md (100%) rename vibe/{ => 2026-08}/2026-08-08-3-gateway-local-inference.md (100%) rename vibe/{ => 2026-08}/2026-08-08-4-add-sys-model.md (100%) rename vibe/{ => 2026-08}/2026-08-08-5-extract-dev-crate.md (100%) rename vibe/{ => 2026-08}/2026-08-08-6-local-toml-bigger-model.md (100%) rename vibe/{ => 2026-08}/2026-08-08-7-gateway-download-progress.md (100%) rename vibe/{ => 2026-08}/2026-08-08-8-tool-dialect-plugins.md (99%) rename vibe/{ => 2026-08}/2026-08-08-9-write-through-store-traces.md (100%) rename vibe/{ => 2026-08}/2026-08-09-1-gateway-web-search-upgrade.md (100%) rename vibe/{ => 2026-08}/2026-08-09-2-promptforge-user-guide.md (100%) rename vibe/{ => 2026-08}/2026-08-09-3-readme-and-ci-overhaul.md (100%) rename vibe/{ => 2026-08}/2026-08-09-4-store-real-plus-virtual.md (100%) rename vibe/{ => 2026-08}/2026-08-09-5-h1-once-no-replay.md (98%) rename vibe/{ => 2026-08}/2026-08-10-1-crate-review.md (100%) rename vibe/{ => 2026-08}/2026-08-11-1-dokuman-all-crates.md (100%) rename vibe/{ => 2026-08}/2026-08-11-2-make-user-guide-crate.md (100%) rename vibe/{ => 2026-08}/2026-08-12-1-mdbook-guide.md (100%) rename vibe/{ => 2026-08}/2026-08-14-1-file-backed-store.md (100%) rename vibe/{ => 2026-08}/2026-08-15-1-store-input-files.md (100%) rename vibe/{ => 2026-08}/2026-08-15-2-quickref-file.md (100%) rename vibe/{ => 2026-08}/2026-08-15-3-gateway-dotenv-support.md (100%) rename vibe/{ => 2026-08}/2026-08-15-4-h1-only-prompt-support.md (100%) rename vibe/{ => 2026-08}/2026-08-16-1-fanout-scope-refactor.md (100%) rename vibe/{ => 2026-08}/2026-08-18-1-tools-local.md (100%) rename vibe/{ => 2026-08}/2026-08-18-2-empty-turn-and-mcp-env.md (100%) rename vibe/{ => 2026-08}/2026-08-18-3-untrusted-global-vm-reorder.md (100%) rename vibe/{ => 2026-08}/2026-08-18-4-fanout-collection-items.md (100%) rename vibe/{ => 2026-08}/2026-08-19-1-collapse-fanout-arm-engine.md (100%) rename vibe/{ => 2026-08}/2026-08-19-2-promptforge-refactor.md (100%) rename vibe/{ => 2026-08}/2026-08-20-1-fix-core-review-findings.md (100%) rename vibe/{ => 2026-08}/2026-08-22-1-runcontext-seed.md (100%) rename vibe/{ => 2026-08}/2026-08-23-1-dominion-refactor.md (99%) rename vibe/{ => 2026-08}/2026-08-23-2-gateway-phases-4-5.md (99%) rename vibe/{ => 2026-08}/2026-08-24-1-stage-1-the-window.md (99%) rename vibe/{ => 2026-08}/2026-08-24-2-autogen-config-first-run.md (99%) rename vibe/{ => 2026-08}/2026-08-24-3-voice-ux-fixes.md (99%) rename vibe/{ => 2026-08}/2026-08-24-4-progressive-transcription.md (99%) rename vibe/{ => 2026-08}/2026-08-24-5-stop-recording-on-send.md (99%) rename vibe/{ => 2026-08}/2026-08-24-6-ci-and-hygiene-fixes.md (100%) rename vibe/{ => 2026-08}/2026-08-25-1-rename-workbench-to-workshop.md (99%) rename vibe/{ => 2026-08}/2026-08-26-1-remove-voice-status-line.md (99%) rename vibe/{ => 2026-08}/2026-08-26-2-model-turn-actions.md (99%) rename vibe/{ => 2026-08}/2026-08-26-3-workshop-regression-fixes.md (100%) rename vibe/{ => 2026-08}/2026-08-26-4-workshop-idiom-refactor.md (99%) rename vibe/{ => 2026-08}/2026-08-27-1-refresh-tree-on-drop.md (99%) rename vibe/{ => 2026-08}/2026-08-27-2-workshop-server-refactor.md (99%) rename vibe/{ => 2026-08}/2026-08-27-3-server-driven-menu-state.md (99%) rename vibe/{ => 2026-08}/2026-08-27-4-merged-gateway-workshop.md (99%) rename vibe/{ => 2026-08}/2026-08-28-1-fix-open-vibe-findings.md (99%) rename vibe/{ => 2026-08}/2026-08-28-2-coroutine-protocol-executor.md (99%) rename vibe/{ => 2026-08}/2026-08-28-3-digest-marker-child-priority.md (99%) rename vibe/{ => 2026-08}/2026-08-28-4-cuda-llama-provisioning.md (99%) rename vibe/{ => 2026-08}/2026-08-29-1-crate-extraction-execution.md (99%) rename vibe/{ => 2026-08}/2026-08-29-2-rename-ws-crates-to-workshop.md (99%) rename vibe/{ => 2026-08}/2026-08-29-3-chat-ws-decomposition.md (99%) rename vibe/{ => 2026-08}/2026-08-29-4-progress-architecture-rollout.md (99%) rename vibe/{ => 2026-08}/2026-08-29-5-gateway-config-spa.md (99%) rename vibe/{ => 2026-08}/2026-08-30-1-chat-templates-injection-defense.md (99%) rename vibe/{ => 2026-08}/2026-08-31-1-fix-hf-proxy-consistency.md (99%) rename vibe/{ => 2026-08}/2026-08-31-2-rebuild-mdbook-from-includes.md (99%) rename vibe/{ => 2026-08}/2026-08-31-3-republish-crates.md (100%) rename vibe/{ => 2026-08}/2026-08-31-4-core-support-api-refinements.md (99%) rename vibe/{ => 2026-08}/2026-08-31-5-desktop-shell-review-followup.md (99%) rename vibe/{ => 2026-08}/2026-08-31-6-tauri-migration-for-workshop.md (99%) rename vibe/{ => 2026-08}/2026-08-31-7-interactive-webhook-tool.md (99%) rename vibe/{ => 2026-09}/2026-09-01-1-build-simplification.md (99%) rename vibe/{ => 2026-09}/2026-09-01-2-nightly-installer-builds.md (99%) rename vibe/{ => 2026-09}/2026-09-02-1-workshop-agent-window-clone.md (99%) rename vibe/{ => 2026-09}/2026-09-02-2-workshop-cursor-parity.md (99%) rename vibe/{ => 2026-09}/2026-09-02-3-whisper-shared-lib.md (99%) rename vibe/{ => 2026-09}/2026-09-02-4-evergreen-links-and-version.md (99%) rename vibe/{ => 2026-09}/2026-09-02-5-crate-taxonomy-rename.md (99%) rename vibe/{ => 2026-09}/2026-09-02-6-product-user-guides.md (99%) rename vibe/{ => 2026-09}/2026-09-03-1-enhance-tts-endpoint-report.md (100%) rename vibe/{ => 2026-09}/2026-09-03-2-enhance-image-endpoint-spec.md (100%) rename vibe/{ => 2026-09}/2026-09-03-3-gateway-sidecar-decomposition.md (99%) rename vibe/{ => 2026-09}/2026-09-04-1-async-boot-and-progress.md (99%) rename vibe/{ => 2026-09}/2026-09-04-2-apply-as-queue-command.md (99%) rename vibe/{ => 2026-09}/2026-09-04-3-unlock-inference-during-switches.md (99%) rename vibe/{ => 2026-09}/2026-09-05-1-gateway-logging-cli.md (100%) rename vibe/{ => 2026-09}/2026-09-05-2-generic-realtime-stt.md (100%) rename vibe/{ => 2026-09}/2026-09-07-1-promptforge-debt.md (100%) rename vibe/{ => 2026-09}/2026-09-07-2-gateway-tts-phase-1.md (100%) rename vibe/{ => 2026-09}/2026-09-08-1-remove-unsupported-ratchets.md (100%) rename vibe/{ => 2026-09}/2026-09-09-2-async-stt-boot-reset.md (100%) diff --git a/vibe/2026-07-29-1-gateway-v0.md b/vibe/2026-07/2026-07-29-1-gateway-v0.md similarity index 100% rename from vibe/2026-07-29-1-gateway-v0.md rename to vibe/2026-07/2026-07-29-1-gateway-v0.md diff --git a/vibe/2026-07-29-2-lua-args-substitution.md b/vibe/2026-07/2026-07-29-2-lua-args-substitution.md similarity index 100% rename from vibe/2026-07-29-2-lua-args-substitution.md rename to vibe/2026-07/2026-07-29-2-lua-args-substitution.md diff --git a/vibe/2026-07-29-3-brave-web-search-tools.md b/vibe/2026-07/2026-07-29-3-brave-web-search-tools.md similarity index 100% rename from vibe/2026-07-29-3-brave-web-search-tools.md rename to vibe/2026-07/2026-07-29-3-brave-web-search-tools.md diff --git a/vibe/2026-07-29-4-webfetch-crate-extraction.md b/vibe/2026-07/2026-07-29-4-webfetch-crate-extraction.md similarity index 100% rename from vibe/2026-07-29-4-webfetch-crate-extraction.md rename to vibe/2026-07/2026-07-29-4-webfetch-crate-extraction.md diff --git a/vibe/2026-07-29-5-multi-turn-research-prompt.md b/vibe/2026-07/2026-07-29-5-multi-turn-research-prompt.md similarity index 100% rename from vibe/2026-07-29-5-multi-turn-research-prompt.md rename to vibe/2026-07/2026-07-29-5-multi-turn-research-prompt.md diff --git a/vibe/2026-07-29-6-per-section-tool-scoping.md b/vibe/2026-07/2026-07-29-6-per-section-tool-scoping.md similarity index 100% rename from vibe/2026-07-29-6-per-section-tool-scoping.md rename to vibe/2026-07/2026-07-29-6-per-section-tool-scoping.md diff --git a/vibe/2026-07-29-7-guard-wrap-untrusted-output.md b/vibe/2026-07/2026-07-29-7-guard-wrap-untrusted-output.md similarity index 100% rename from vibe/2026-07-29-7-guard-wrap-untrusted-output.md rename to vibe/2026-07/2026-07-29-7-guard-wrap-untrusted-output.md diff --git a/vibe/2026-07-31-1-orchestrator-only.md b/vibe/2026-07/2026-07-31-1-orchestrator-only.md similarity index 100% rename from vibe/2026-07-31-1-orchestrator-only.md rename to vibe/2026-07/2026-07-31-1-orchestrator-only.md diff --git a/vibe/2026-08-02-1-tool-picker.md b/vibe/2026-08/2026-08-02-1-tool-picker.md similarity index 99% rename from vibe/2026-08-02-1-tool-picker.md rename to vibe/2026-08/2026-08-02-1-tool-picker.md index 4aedcb25..0e2ec0f8 100644 --- a/vibe/2026-08-02-1-tool-picker.md +++ b/vibe/2026-08/2026-08-02-1-tool-picker.md @@ -1,217 +1,217 @@ ---- -name: promptforge tool picker -overview: "Build promptforge-tool-picker: a pure, deterministic, embedding-based tool-resolution engine that takes an abstract tool catalog and resolves plain-English capability needs to a concrete tool, a shortlist, or a loud abstention - with no Lua, MCP, or protocol dependencies." -todos: - - id: rename-study - content: "Rename directories first so all later paths are final: git mv promptforge-design/study-mcp-toolpicker -> study-tool-picker and mcp-classifier-spike -> spike-tool-picker; rename design-mcp-toolpicker.md -> design-tool-picker.md; replace promptforge-mcp-toolpicker -> promptforge-tool-picker across the study docs; update STATUS.md." - status: completed - - id: crate-skeleton - content: Create promptforge/crates/promptforge-tool-picker/ with Cargo.toml (lib.rs, module stubs, [lints] workspace = true) AND add it to the workspace root members list in the same step, so workspace inheritance resolves and every later step can build/test. - status: completed - - id: catalog-types - content: Define ToolDescriptor and Catalog with serde; enriched-text derivation (name + description + parameter names); unit tests. - status: completed - - id: config-errors - content: Config type (model_id default bge-small-en-v1.5, similarity_floor, margin, duplicate_threshold ~0.98, top_k; no weights path) and thiserror Error type with documented defaults. - status: completed - - id: model-assets - content: "build.rs downloads bge-small-en-v1.5 fp16 (safetensors + tokenizer.json + config.json) from HuggingFace pinned to a commit SHA via hf-hub into OUT_DIR, sha2-verified; crate include_bytes!-embeds them. Weights stay out of git." - status: completed - - id: embedding-backend - content: "Candle + candle-transformers + tokenizers: load embedded weights via VarBuilder::from_buffered_safetensors and Tokenizer::from_bytes; use the model's correct pooling (CLS for bge-small-en-v1.5) then L2-normalize; tests for output dimension and run-to-run determinism (golden vector)." - status: completed - - id: index-build - content: ToolPicker::build embeds the whole catalog once and stores vectors in memory (no persistent cache in v1). - status: completed - - id: ranking - content: Cosine ranking + top-k ordering with tests. - status: completed - - id: policy - content: Four-outcome policy (floor, margin, duplicate threshold, annotation tie-break) -> Outcome; one test per outcome with crafted mini-catalogs. - status: completed - - id: public-api - content: Public API build/resolve/shortlist tied together; integration test. - status: completed - - id: behavior-fixtures - content: In-crate behavior/determinism tests over small committed fixtures under tests/fixtures/ (the four outcomes + golden vector). No reference to the design repo. The full-corpus evidence run stays in the study repo as an opt-in harness, not part of cargo test. - status: completed - - id: workspace-verify - content: Verify cargo build/clippy/test clean for the whole workspace; bump MSRV only if Candle requires it. (Workspace membership was added in the skeleton step.) - status: completed - - id: design-doc-gen - content: "Final step: spawn a subagent to read this plan, grep , and generate design-tool-picker.md at the crate root (promptforge/crates/promptforge-tool-picker/) from the finished work." - status: completed -isProject: false ---- - -# Build the promptforge-tool-picker crate - -A pure Rust engine that ingests an abstract tool catalog, embeds each tool once with a bundled CPU sentence-transformer, and resolves a plain-English capability need to one of four outcomes. It carries no Lua, no MCP/protocol, and no network dependency. Governing design: `design-tool-picker.md` and its evidence `RESULTS.md` in `promptforge-design/study-tool-picker/` (both under their pre-rename names `design-mcp-toolpicker.md` / `study-mcp-toolpicker/` until step 1 renames them), as narrowed by the decisions below. - -## Decisions that override the design doc - -- Crate name is `promptforge-tool-picker` (was `promptforge-mcp-toolpicker`); rename across all study docs. Also rename the study directories: `study-mcp-toolpicker` -> `study-tool-picker` and `mcp-classifier-spike` -> `spike-tool-picker`. Done first (step 1) so all later paths are final. -- No Lua dependency and no `promptforge-mcp-client` dependency. The Lua verbs (`tools.add_need`, `choose_mcp_tool`) and the context-rewrite hook are NOT in this crate - they are future integration-layer work in the caller. This crate exposes a Rust API only. -- Likely no `promptforge-core` dependency: the engine returns tool descriptors, not `dyn Tool`; the caller maps a chosen descriptor to a concrete tool. -- The catalog is the sole input contract and its type lives in this crate. -- The `promptforge` repo must never reference the `promptforge-design` repo. No crate code, test, fixture, or build step reads from `promptforge-design/`. Any preprocessing facts needed from the study (pooling, thresholds) are transcribed into this plan and the crate, not linked at build or test time. (The study rename in step 1 and the design-doc write in step 13 operate on the design repo but are not the crate referring to it.) -- v1 embeds only bge-small-en-v1.5 and uses CLS pooling. all-MiniLM-L6-v2 (mean pooling) is deferred; do not expose a model choice the binary cannot satisfy. -- The generated architect design document lives at the crate root: `promptforge/crates/promptforge-tool-picker/design-tool-picker.md`. It is the crate's own living design doc (inside the `promptforge` repo), separate from the study's historical writeup in `promptforge-design`. - -## Boundary - -```mermaid -flowchart LR - producer["Catalog producer (CLI / future mcp-client / tests)"] -->|"Catalog"| picker["promptforge-tool-picker (embed + rank + policy)"] - picker -->|"Outcome: Bind | Duplicate | Ambiguous | Absent"| caller["Integration layer (Lua verbs, context-rewrite) - out of scope"] -``` - -## Public API contract (Rust-facing, normative) - -- `ToolDescriptor { id, server, name, description, input_schema, annotations }` and `Catalog` (collection). Enriched text for embedding = name + description + parameter names (per design). -- `ToolPicker::build(catalog: Catalog, config: Config) -> Result` - embeds the whole catalog once; deterministic. -- `ToolPicker::resolve(need: &str) -> Outcome` - the four-outcome policy for a static binding. -- `ToolPicker::shortlist(need: &str, k: usize) -> Vec` - deterministic top-k retriever powering dynamic discovery in the caller. -- `Outcome = Bind(ToolDescriptor) | Duplicate(Vec) | Ambiguous(Vec) | Absent`. (Renamed from `ForeignAmbiguous` during implementation: it is the residual bucket for any near-tie the margin could not separate, including same-server non-twin ties, so a name asserting foreign provenance was false in cases the tests exercise.) -- `duplicate_threshold` is a TOOL-TO-TOOL cosine between two tools' own embeddings, independent of any query - which is what the 0.98 default was calibrated as. It is not a query-score level and not a tolerance on the gap between two scores. Consequently `Config` does not order it against `similarity_floor`; those are different measures. Open fork worth recording: a pair of paraphrased (not verbatim) same-server tools that are genuinely indistinguishable but sit below 0.98 will not be reported as `Duplicate`. Falsifier: an evaluation case where two paraphrased same-server tools tie below 0.98 and silently bind. -- `Config { model_id (v1: bge-small-en-v1.5 only), similarity_floor, margin, duplicate_threshold (~0.98), top_k }`. No weights path: weights are embedded in the binary. - -## Engine specifics - -- Matcher: local sentence embeddings via Candle + `candle-transformers` (BERT), tokenizer via `tokenizers`, CPU, deterministic. No LLM in-crate. Pooling is model-specific and must match the model: bge-small-en-v1.5 uses CLS-token pooling (not mean), then L2-normalize. Replicate exactly the preprocessing the study used (pooling, normalization, any bge query prefix), transcribed into the crate - the crate does not read the study repo. -- Policy: cosine rank; clear top-1 above floor with sufficient top1-vs-top2 margin -> Bind; near-tie at/above duplicate threshold within the author's own catalog -> fail loud (Duplicate); near-tie across intentionally imported/foreign servers -> ForeignAmbiguous shortlist; nothing above floor -> Absent. MCP `readOnlyHint`/`destructiveHint`/`idempotentHint` annotations break ties only where present. -- Model weights are never committed to git. `build.rs` downloads the model from HuggingFace pinned to a commit SHA (via `hf-hub`, cached in `~/.cache/huggingface`, verified with `sha2`) into `OUT_DIR` (under `target/`, already gitignored). The crate then `include_bytes!`-embeds the safetensors + `tokenizer.json` + `config.json` from `OUT_DIR`, so the weights compile into the `.rlib` and any linked binary carries them - no external file, no network, at runtime. Load from memory: `Tokenizer::from_bytes(...)` and Candle `VarBuilder::from_buffered_safetensors(...)`. -- Embed fp16 weights (~65MB) rather than fp32 (~130MB) to limit per-binary bloat; cosine similarity is unaffected in practice. bge-small-en-v1.5 ships fp32 on HuggingFace, so `build.rs` downcasts to fp16 before `include_bytes!`. Falsifier: a measurable accuracy drop on the opt-in study eval. Reversing to fp32 is a build-time change only. -- Do NOT use `tch`/rust-bert (they link native libtorch, an external runtime dependency that defeats embedding). Candle is pure Rust with in-memory safetensors loading. -- Side benefit of dropping MCP: no `rmcp`, so no forced workspace MSRV bump to 1.88; verify Candle builds under edition 2024 / rust 1.85 and bump only if Candle requires it. - -## Dependencies - -- Runtime: `candle-core`, `candle-nn`, `candle-transformers` (BERT), `tokenizers`, `serde`/`serde_json`, `thiserror`. -- Build (`[build-dependencies]`): `hf-hub` (pinned-revision download), `sha2` (checksum verification), `safetensors` (read/write the tensor file), `half` (correct round-to-nearest-even fp32 to fp16 conversion, rather than hand-rolling IEEE 754 binary16). - -## Measured findings from the built engine - -These came out of the fixture tests and belong in the design document as named tensions: - -- The duplicate threshold is sensitive to description LENGTH, not only to how alike two tools are. Two tools sharing a description word for word, with names differing by one word, measure 0.983 when the description is a paragraph but only 0.960 when it is a single line - the name difference is a large fraction of a short text. So verbatim copies under short descriptions escape the 0.98 threshold, which sharpens the recorded falsifier: it is not only paraphrases that slip through. A genuinely paraphrased same-server pair measured 0.811. -- The 0.825 similarity floor is stricter than it reads, and that is the design working as calibrated. A need that restates a tool ("get the weather forecast for a city") scores 0.865 and binds; the way a person would actually ask ("what will the weather be like in Paris this week") scores 0.651 and abstains. The floor earns its 5% false-bind budget by binding only near-restatements. Consequence for callers: `resolve` is for author-register capability descriptions, and real end-user phrasing should expect `Absent` far more often. `shortlist` with a lowered floor is the honest entry point for that traffic. -- Loading the model twice in a row is measurably slower than once (the loader materializes ~133MB of f32 weights), so proving determinism across two builds costs about 6.6s. A future improvement would let callers share one loaded `Embedder` across engines. - -## Build method - -Adopt the Vibe rulebook ([vibe-how-to.md](tools-public/how-to/vibe-how-to.md)) as the execution method: one testable commit per step, each written in a subagent, reviewed in a fresh subagent that writes findings to `vibe-review.md` (overwritten each cycle), fixed in a third; git stays in the main context. Each step handed to its subagent by this plan's path plus the step number. Learn house rules first: workspace lints (forbid unsafe, deny unwrap/expect, warn missing_docs, clippy pedantic), edition 2024, `[lints] workspace = true`, thiserror for library errors, `///` on every public item, and update STATUS.md on commits (see [AGENTS.md](promptforge/AGENTS.md)). Use `promptforge-webfetch` as the crate-structure template. - - -Project-specific review checks, in addition to the general code-review block: -- No dependency on Lua, MCP/rmcp, or a network client anywhere in the crate. -- Every public item has `///` docs with `# Errors` where fallible. -- Resolution is deterministic: same catalog + need + config yields the same outcome across runs. -- The four outcomes are each exercised by a test with a crafted mini-catalog. - - -## Steps - -Work them in order; each is one commit carrying code, test, and docs. - -1. Renames first, so every later path is final: `git mv promptforge-design/study-mcp-toolpicker promptforge-design/study-tool-picker` and `git mv promptforge-design/mcp-classifier-spike promptforge-design/spike-tool-picker` (the spike's local `.venv` is untracked and can be left or regenerated); rename `design-mcp-toolpicker.md` -> `design-tool-picker.md`; replace `promptforge-mcp-toolpicker` -> `promptforge-tool-picker` across the study docs (design-tool-picker.md, manifest.md, rationale.md, README.md, RESULTS.md); update STATUS.md. -2. Crate skeleton: `promptforge/crates/promptforge-tool-picker/` with Cargo.toml (`[lints] workspace = true`), lib.rs, module stubs, AND add the crate to the workspace root `members` list in this same step - workspace inheritance (`workspace = true`, `workspace.dependencies`) will not resolve otherwise, so every later step's build/test depends on this. -3. Catalog types: `ToolDescriptor`, `Catalog`, serde derives, enriched-text derivation (name + description + parameter names), unit tests. -4. Config and Error types (thiserror), thresholds with documented defaults (no weights path; weights are embedded). -5. Model assets via `build.rs`: `hf-hub` downloads bge-small-en-v1.5 (fp16 safetensors + tokenizer.json + config.json) pinned to a commit SHA into `OUT_DIR`, `sha2`-verified; the crate `include_bytes!`-embeds them. Weights stay out of git (OUT_DIR is under target/). -6. Embedding backend: load embedded weights via Candle `VarBuilder::from_buffered_safetensors` and tokenizer via `Tokenizer::from_bytes`; tokenize + CLS-pool (bge) + L2-normalize; tests for output dimension and run-to-run determinism (golden vector), all from in-memory bytes. -7. Index build: embed the whole catalog in `ToolPicker::build`; store vectors in memory (no persistent cache in v1). -8. Cosine ranking + top-k ordering, with tests. -9. Four-outcome policy (floor, margin, duplicate threshold, annotation tie-break) -> `Outcome`; one test per outcome using crafted mini-catalogs. -10. Public API: `build` / `resolve` / `shortlist` tied together; integration test. -11. Behavior + determinism tests over small fixtures committed under the crate's own `tests/fixtures/` (the four outcomes and a golden vector). No path into `promptforge-design`. The full-corpus evidence run (29k cases / 9,922-tool catalog) stays in the study repo as an opt-in harness and is not part of `cargo test`. -12. Verify `cargo build`/`clippy --all-targets --all-features -- -D warnings`/`test` clean for the whole workspace; bump MSRV only if Candle requires it. (Workspace membership was added in step 2.) -13. After implementation is complete, generate the design document: spawn one subagent whose entire prompt is - read this plan file, grep for ``, and follow the block inside it. It writes `design-tool-picker.md` at the crate root, `promptforge/crates/promptforge-tool-picker/design-tool-picker.md`. Set `{slug}` = `tool-picker`. - - -OUTPUT A DESIGN DOCUMENT, NOT CODE. Write one markdown file, design-tool-picker.md, -that explains the design of what this plan describes. You run as the final step -of the plan, after the implementation is complete, so describe the design as -built, reconciling against the finished work any decision the implementation -changed from what this plan first recorded. - -NO IMPLEMENTATION CODE - no function bodies, no private machinery, no -step-by-step algorithm walkthroughs. You MAY include any normative artifact the -design needs to remove ambiguity: public signatures, schemas, state or -transition tables, wire formats, configuration syntax, sequence diagrams, and -pseudocode. Each such artifact must express a design contract, not an -implementation technique; include one only where prose cannot say the same -thing as precisely, and show the artifact alone, not the surrounding machinery. - -FOR EVERY DESIGN ELEMENT, STATE THREE THINGS: what is observed (by the user or -by an external consumer), how it is structured, and WHY - the motivation, the -rationale, the principle. For a costly-to-reverse element, "why" must include -what reversing it later would cost. - -DESIGN-ELEMENT TEST - include something only if changing it would change ANY of: - (a) ANYTHING THE USER SEES, READS, WRITES, TYPES, OR NAMES. For a library the - user is the caller, so this is the PUBLIC API - its operations and their - contracts (ownership, lifetime, thread-safety, error and complexity - guarantees). It also includes every config file or frontmatter the user - edits, and - critically - the NAMES of everything the user sees. A name - is a design decision: `goto` is a good one, `clear_and_transfer_control` - is a bad one. Naming is design. - (b) the shape or structure of the system. - (c) something costly or hard to reverse that the user never sees - the ABI, - an on-disk or persisted format that outlives a version, a high-reach - convention that touches everything, or a cross-cutting quality trade-off - (security, failure modes, data lifecycle, performance). -If it is none of these - merely how you implement the design behind those -surfaces, such as a private helper type, an internal algorithm choice, a -dependency version pin, or a serialization used only between your own -components - it is implementation. Leave it out. - -A public interface is design; a private type is implementation - the same -struct is on opposite sides of the line depending on whether the user sees it. -Describe an interface's shape and contract in prose by default; show the actual -artifact - a signature, a schema, a state table - wherever that artifact is -itself the load-bearing decision and prose would blur it. No fixed budget binds -these; each earns its place only by being load-bearing. - -COMPRESS BEFORE WRITING - only if the design carries far more ditchable detail -than load-bearing decisions (roughly 10 to 1 or worse). If it is already lean, -skip this. Run the pass in order, cheapest cut first, and stop once the ratio -is healthy: - 1. Drop a default only when changing it would change no observable behavior - and carry no meaningful risk. A consequential default - a timeout, - ownership, a security posture, a retry policy, a resource limit, a - compatibility choice, a failure mode - resolved a real fork and stays. - 2. Move anything decidable later at little or no extra cost to a "decide by - use" list, or drop it. A cheaply-deferrable element is not a headline one. - 3. Replace an enumeration with the rule that generates it. - 4. Merge consequences into the decision that forces them, and sibling - elements into their shared pattern. - 5. Name a known pattern instead of re-deriving it. - 6. Rank what remains and keep about 10 to 15 headline elements; demote the - rest to one line. - 7. Delete anything whose removal would still let a competent builder build - the right thing. - -STRUCTURE - three fixed sections, then whatever the design earns: - - A title stating what building this produces. - - An executive summary that stands alone; a reader acts on it without the body. - - A numbered list of the 10 to 15 key design choices, each a short paragraph. -Then, for a reader who stops early: - - Write headings that state the point, not the topic ("Labels compute at - boot, off the critical path", not "Labels"). - - Keep rationale in prose; do not bulletize an argument. Enumerate only - parallel items (decisions, constraints, options). - - State the evidence before the value word: never "fast" before the number. - - Where a choice resolved a real fork, name the alternative and why it lost. - - Order by importance; put a dependency first only where the reader needs it - to follow what comes next, so cutting from the bottom never removes the core. - - Add no YAML frontmatter. Close with one italic line naming the date and the - model. Name no tool, rulebook, or source document for the document's own - rules or structure. - -CHECK BEFORE FINISHING, and fix any no: no implementation code, and every -normative artifact expresses a contract rather than a technique; every element -states what, how, and why; headings state points; no argument is bulletized; -the compression ratio is healthy; no source document is named. If the plan -carries no key design choices, write no document and return the reason. +--- +name: promptforge tool picker +overview: "Build promptforge-tool-picker: a pure, deterministic, embedding-based tool-resolution engine that takes an abstract tool catalog and resolves plain-English capability needs to a concrete tool, a shortlist, or a loud abstention - with no Lua, MCP, or protocol dependencies." +todos: + - id: rename-study + content: "Rename directories first so all later paths are final: git mv promptforge-design/study-mcp-toolpicker -> study-tool-picker and mcp-classifier-spike -> spike-tool-picker; rename design-mcp-toolpicker.md -> design-tool-picker.md; replace promptforge-mcp-toolpicker -> promptforge-tool-picker across the study docs; update STATUS.md." + status: completed + - id: crate-skeleton + content: Create promptforge/crates/promptforge-tool-picker/ with Cargo.toml (lib.rs, module stubs, [lints] workspace = true) AND add it to the workspace root members list in the same step, so workspace inheritance resolves and every later step can build/test. + status: completed + - id: catalog-types + content: Define ToolDescriptor and Catalog with serde; enriched-text derivation (name + description + parameter names); unit tests. + status: completed + - id: config-errors + content: Config type (model_id default bge-small-en-v1.5, similarity_floor, margin, duplicate_threshold ~0.98, top_k; no weights path) and thiserror Error type with documented defaults. + status: completed + - id: model-assets + content: "build.rs downloads bge-small-en-v1.5 fp16 (safetensors + tokenizer.json + config.json) from HuggingFace pinned to a commit SHA via hf-hub into OUT_DIR, sha2-verified; crate include_bytes!-embeds them. Weights stay out of git." + status: completed + - id: embedding-backend + content: "Candle + candle-transformers + tokenizers: load embedded weights via VarBuilder::from_buffered_safetensors and Tokenizer::from_bytes; use the model's correct pooling (CLS for bge-small-en-v1.5) then L2-normalize; tests for output dimension and run-to-run determinism (golden vector)." + status: completed + - id: index-build + content: ToolPicker::build embeds the whole catalog once and stores vectors in memory (no persistent cache in v1). + status: completed + - id: ranking + content: Cosine ranking + top-k ordering with tests. + status: completed + - id: policy + content: Four-outcome policy (floor, margin, duplicate threshold, annotation tie-break) -> Outcome; one test per outcome with crafted mini-catalogs. + status: completed + - id: public-api + content: Public API build/resolve/shortlist tied together; integration test. + status: completed + - id: behavior-fixtures + content: In-crate behavior/determinism tests over small committed fixtures under tests/fixtures/ (the four outcomes + golden vector). No reference to the design repo. The full-corpus evidence run stays in the study repo as an opt-in harness, not part of cargo test. + status: completed + - id: workspace-verify + content: Verify cargo build/clippy/test clean for the whole workspace; bump MSRV only if Candle requires it. (Workspace membership was added in the skeleton step.) + status: completed + - id: design-doc-gen + content: "Final step: spawn a subagent to read this plan, grep , and generate design-tool-picker.md at the crate root (promptforge/crates/promptforge-tool-picker/) from the finished work." + status: completed +isProject: false +--- + +# Build the promptforge-tool-picker crate + +A pure Rust engine that ingests an abstract tool catalog, embeds each tool once with a bundled CPU sentence-transformer, and resolves a plain-English capability need to one of four outcomes. It carries no Lua, no MCP/protocol, and no network dependency. Governing design: `design-tool-picker.md` and its evidence `RESULTS.md` in `promptforge-design/study-tool-picker/` (both under their pre-rename names `design-mcp-toolpicker.md` / `study-mcp-toolpicker/` until step 1 renames them), as narrowed by the decisions below. + +## Decisions that override the design doc + +- Crate name is `promptforge-tool-picker` (was `promptforge-mcp-toolpicker`); rename across all study docs. Also rename the study directories: `study-mcp-toolpicker` -> `study-tool-picker` and `mcp-classifier-spike` -> `spike-tool-picker`. Done first (step 1) so all later paths are final. +- No Lua dependency and no `promptforge-mcp-client` dependency. The Lua verbs (`tools.add_need`, `choose_mcp_tool`) and the context-rewrite hook are NOT in this crate - they are future integration-layer work in the caller. This crate exposes a Rust API only. +- Likely no `promptforge-core` dependency: the engine returns tool descriptors, not `dyn Tool`; the caller maps a chosen descriptor to a concrete tool. +- The catalog is the sole input contract and its type lives in this crate. +- The `promptforge` repo must never reference the `promptforge-design` repo. No crate code, test, fixture, or build step reads from `promptforge-design/`. Any preprocessing facts needed from the study (pooling, thresholds) are transcribed into this plan and the crate, not linked at build or test time. (The study rename in step 1 and the design-doc write in step 13 operate on the design repo but are not the crate referring to it.) +- v1 embeds only bge-small-en-v1.5 and uses CLS pooling. all-MiniLM-L6-v2 (mean pooling) is deferred; do not expose a model choice the binary cannot satisfy. +- The generated architect design document lives at the crate root: `promptforge/crates/promptforge-tool-picker/design-tool-picker.md`. It is the crate's own living design doc (inside the `promptforge` repo), separate from the study's historical writeup in `promptforge-design`. + +## Boundary + +```mermaid +flowchart LR + producer["Catalog producer (CLI / future mcp-client / tests)"] -->|"Catalog"| picker["promptforge-tool-picker (embed + rank + policy)"] + picker -->|"Outcome: Bind | Duplicate | Ambiguous | Absent"| caller["Integration layer (Lua verbs, context-rewrite) - out of scope"] +``` + +## Public API contract (Rust-facing, normative) + +- `ToolDescriptor { id, server, name, description, input_schema, annotations }` and `Catalog` (collection). Enriched text for embedding = name + description + parameter names (per design). +- `ToolPicker::build(catalog: Catalog, config: Config) -> Result` - embeds the whole catalog once; deterministic. +- `ToolPicker::resolve(need: &str) -> Outcome` - the four-outcome policy for a static binding. +- `ToolPicker::shortlist(need: &str, k: usize) -> Vec` - deterministic top-k retriever powering dynamic discovery in the caller. +- `Outcome = Bind(ToolDescriptor) | Duplicate(Vec) | Ambiguous(Vec) | Absent`. (Renamed from `ForeignAmbiguous` during implementation: it is the residual bucket for any near-tie the margin could not separate, including same-server non-twin ties, so a name asserting foreign provenance was false in cases the tests exercise.) +- `duplicate_threshold` is a TOOL-TO-TOOL cosine between two tools' own embeddings, independent of any query - which is what the 0.98 default was calibrated as. It is not a query-score level and not a tolerance on the gap between two scores. Consequently `Config` does not order it against `similarity_floor`; those are different measures. Open fork worth recording: a pair of paraphrased (not verbatim) same-server tools that are genuinely indistinguishable but sit below 0.98 will not be reported as `Duplicate`. Falsifier: an evaluation case where two paraphrased same-server tools tie below 0.98 and silently bind. +- `Config { model_id (v1: bge-small-en-v1.5 only), similarity_floor, margin, duplicate_threshold (~0.98), top_k }`. No weights path: weights are embedded in the binary. + +## Engine specifics + +- Matcher: local sentence embeddings via Candle + `candle-transformers` (BERT), tokenizer via `tokenizers`, CPU, deterministic. No LLM in-crate. Pooling is model-specific and must match the model: bge-small-en-v1.5 uses CLS-token pooling (not mean), then L2-normalize. Replicate exactly the preprocessing the study used (pooling, normalization, any bge query prefix), transcribed into the crate - the crate does not read the study repo. +- Policy: cosine rank; clear top-1 above floor with sufficient top1-vs-top2 margin -> Bind; near-tie at/above duplicate threshold within the author's own catalog -> fail loud (Duplicate); near-tie across intentionally imported/foreign servers -> ForeignAmbiguous shortlist; nothing above floor -> Absent. MCP `readOnlyHint`/`destructiveHint`/`idempotentHint` annotations break ties only where present. +- Model weights are never committed to git. `build.rs` downloads the model from HuggingFace pinned to a commit SHA (via `hf-hub`, cached in `~/.cache/huggingface`, verified with `sha2`) into `OUT_DIR` (under `target/`, already gitignored). The crate then `include_bytes!`-embeds the safetensors + `tokenizer.json` + `config.json` from `OUT_DIR`, so the weights compile into the `.rlib` and any linked binary carries them - no external file, no network, at runtime. Load from memory: `Tokenizer::from_bytes(...)` and Candle `VarBuilder::from_buffered_safetensors(...)`. +- Embed fp16 weights (~65MB) rather than fp32 (~130MB) to limit per-binary bloat; cosine similarity is unaffected in practice. bge-small-en-v1.5 ships fp32 on HuggingFace, so `build.rs` downcasts to fp16 before `include_bytes!`. Falsifier: a measurable accuracy drop on the opt-in study eval. Reversing to fp32 is a build-time change only. +- Do NOT use `tch`/rust-bert (they link native libtorch, an external runtime dependency that defeats embedding). Candle is pure Rust with in-memory safetensors loading. +- Side benefit of dropping MCP: no `rmcp`, so no forced workspace MSRV bump to 1.88; verify Candle builds under edition 2024 / rust 1.85 and bump only if Candle requires it. + +## Dependencies + +- Runtime: `candle-core`, `candle-nn`, `candle-transformers` (BERT), `tokenizers`, `serde`/`serde_json`, `thiserror`. +- Build (`[build-dependencies]`): `hf-hub` (pinned-revision download), `sha2` (checksum verification), `safetensors` (read/write the tensor file), `half` (correct round-to-nearest-even fp32 to fp16 conversion, rather than hand-rolling IEEE 754 binary16). + +## Measured findings from the built engine + +These came out of the fixture tests and belong in the design document as named tensions: + +- The duplicate threshold is sensitive to description LENGTH, not only to how alike two tools are. Two tools sharing a description word for word, with names differing by one word, measure 0.983 when the description is a paragraph but only 0.960 when it is a single line - the name difference is a large fraction of a short text. So verbatim copies under short descriptions escape the 0.98 threshold, which sharpens the recorded falsifier: it is not only paraphrases that slip through. A genuinely paraphrased same-server pair measured 0.811. +- The 0.825 similarity floor is stricter than it reads, and that is the design working as calibrated. A need that restates a tool ("get the weather forecast for a city") scores 0.865 and binds; the way a person would actually ask ("what will the weather be like in Paris this week") scores 0.651 and abstains. The floor earns its 5% false-bind budget by binding only near-restatements. Consequence for callers: `resolve` is for author-register capability descriptions, and real end-user phrasing should expect `Absent` far more often. `shortlist` with a lowered floor is the honest entry point for that traffic. +- Loading the model twice in a row is measurably slower than once (the loader materializes ~133MB of f32 weights), so proving determinism across two builds costs about 6.6s. A future improvement would let callers share one loaded `Embedder` across engines. + +## Build method + +Adopt the Vibe rulebook ([vibe-how-to.md](tools-public/how-to/vibe-how-to.md)) as the execution method: one testable commit per step, each written in a subagent, reviewed in a fresh subagent that writes findings to `vibe-review.md` (overwritten each cycle), fixed in a third; git stays in the main context. Each step handed to its subagent by this plan's path plus the step number. Learn house rules first: workspace lints (forbid unsafe, deny unwrap/expect, warn missing_docs, clippy pedantic), edition 2024, `[lints] workspace = true`, thiserror for library errors, `///` on every public item, and update STATUS.md on commits (see [AGENTS.md](promptforge/AGENTS.md)). Use `promptforge-webfetch` as the crate-structure template. + + +Project-specific review checks, in addition to the general code-review block: +- No dependency on Lua, MCP/rmcp, or a network client anywhere in the crate. +- Every public item has `///` docs with `# Errors` where fallible. +- Resolution is deterministic: same catalog + need + config yields the same outcome across runs. +- The four outcomes are each exercised by a test with a crafted mini-catalog. + + +## Steps + +Work them in order; each is one commit carrying code, test, and docs. + +1. Renames first, so every later path is final: `git mv promptforge-design/study-mcp-toolpicker promptforge-design/study-tool-picker` and `git mv promptforge-design/mcp-classifier-spike promptforge-design/spike-tool-picker` (the spike's local `.venv` is untracked and can be left or regenerated); rename `design-mcp-toolpicker.md` -> `design-tool-picker.md`; replace `promptforge-mcp-toolpicker` -> `promptforge-tool-picker` across the study docs (design-tool-picker.md, manifest.md, rationale.md, README.md, RESULTS.md); update STATUS.md. +2. Crate skeleton: `promptforge/crates/promptforge-tool-picker/` with Cargo.toml (`[lints] workspace = true`), lib.rs, module stubs, AND add the crate to the workspace root `members` list in this same step - workspace inheritance (`workspace = true`, `workspace.dependencies`) will not resolve otherwise, so every later step's build/test depends on this. +3. Catalog types: `ToolDescriptor`, `Catalog`, serde derives, enriched-text derivation (name + description + parameter names), unit tests. +4. Config and Error types (thiserror), thresholds with documented defaults (no weights path; weights are embedded). +5. Model assets via `build.rs`: `hf-hub` downloads bge-small-en-v1.5 (fp16 safetensors + tokenizer.json + config.json) pinned to a commit SHA into `OUT_DIR`, `sha2`-verified; the crate `include_bytes!`-embeds them. Weights stay out of git (OUT_DIR is under target/). +6. Embedding backend: load embedded weights via Candle `VarBuilder::from_buffered_safetensors` and tokenizer via `Tokenizer::from_bytes`; tokenize + CLS-pool (bge) + L2-normalize; tests for output dimension and run-to-run determinism (golden vector), all from in-memory bytes. +7. Index build: embed the whole catalog in `ToolPicker::build`; store vectors in memory (no persistent cache in v1). +8. Cosine ranking + top-k ordering, with tests. +9. Four-outcome policy (floor, margin, duplicate threshold, annotation tie-break) -> `Outcome`; one test per outcome using crafted mini-catalogs. +10. Public API: `build` / `resolve` / `shortlist` tied together; integration test. +11. Behavior + determinism tests over small fixtures committed under the crate's own `tests/fixtures/` (the four outcomes and a golden vector). No path into `promptforge-design`. The full-corpus evidence run (29k cases / 9,922-tool catalog) stays in the study repo as an opt-in harness and is not part of `cargo test`. +12. Verify `cargo build`/`clippy --all-targets --all-features -- -D warnings`/`test` clean for the whole workspace; bump MSRV only if Candle requires it. (Workspace membership was added in step 2.) +13. After implementation is complete, generate the design document: spawn one subagent whose entire prompt is - read this plan file, grep for ``, and follow the block inside it. It writes `design-tool-picker.md` at the crate root, `promptforge/crates/promptforge-tool-picker/design-tool-picker.md`. Set `{slug}` = `tool-picker`. + + +OUTPUT A DESIGN DOCUMENT, NOT CODE. Write one markdown file, design-tool-picker.md, +that explains the design of what this plan describes. You run as the final step +of the plan, after the implementation is complete, so describe the design as +built, reconciling against the finished work any decision the implementation +changed from what this plan first recorded. + +NO IMPLEMENTATION CODE - no function bodies, no private machinery, no +step-by-step algorithm walkthroughs. You MAY include any normative artifact the +design needs to remove ambiguity: public signatures, schemas, state or +transition tables, wire formats, configuration syntax, sequence diagrams, and +pseudocode. Each such artifact must express a design contract, not an +implementation technique; include one only where prose cannot say the same +thing as precisely, and show the artifact alone, not the surrounding machinery. + +FOR EVERY DESIGN ELEMENT, STATE THREE THINGS: what is observed (by the user or +by an external consumer), how it is structured, and WHY - the motivation, the +rationale, the principle. For a costly-to-reverse element, "why" must include +what reversing it later would cost. + +DESIGN-ELEMENT TEST - include something only if changing it would change ANY of: + (a) ANYTHING THE USER SEES, READS, WRITES, TYPES, OR NAMES. For a library the + user is the caller, so this is the PUBLIC API - its operations and their + contracts (ownership, lifetime, thread-safety, error and complexity + guarantees). It also includes every config file or frontmatter the user + edits, and - critically - the NAMES of everything the user sees. A name + is a design decision: `goto` is a good one, `clear_and_transfer_control` + is a bad one. Naming is design. + (b) the shape or structure of the system. + (c) something costly or hard to reverse that the user never sees - the ABI, + an on-disk or persisted format that outlives a version, a high-reach + convention that touches everything, or a cross-cutting quality trade-off + (security, failure modes, data lifecycle, performance). +If it is none of these - merely how you implement the design behind those +surfaces, such as a private helper type, an internal algorithm choice, a +dependency version pin, or a serialization used only between your own +components - it is implementation. Leave it out. + +A public interface is design; a private type is implementation - the same +struct is on opposite sides of the line depending on whether the user sees it. +Describe an interface's shape and contract in prose by default; show the actual +artifact - a signature, a schema, a state table - wherever that artifact is +itself the load-bearing decision and prose would blur it. No fixed budget binds +these; each earns its place only by being load-bearing. + +COMPRESS BEFORE WRITING - only if the design carries far more ditchable detail +than load-bearing decisions (roughly 10 to 1 or worse). If it is already lean, +skip this. Run the pass in order, cheapest cut first, and stop once the ratio +is healthy: + 1. Drop a default only when changing it would change no observable behavior + and carry no meaningful risk. A consequential default - a timeout, + ownership, a security posture, a retry policy, a resource limit, a + compatibility choice, a failure mode - resolved a real fork and stays. + 2. Move anything decidable later at little or no extra cost to a "decide by + use" list, or drop it. A cheaply-deferrable element is not a headline one. + 3. Replace an enumeration with the rule that generates it. + 4. Merge consequences into the decision that forces them, and sibling + elements into their shared pattern. + 5. Name a known pattern instead of re-deriving it. + 6. Rank what remains and keep about 10 to 15 headline elements; demote the + rest to one line. + 7. Delete anything whose removal would still let a competent builder build + the right thing. + +STRUCTURE - three fixed sections, then whatever the design earns: + - A title stating what building this produces. + - An executive summary that stands alone; a reader acts on it without the body. + - A numbered list of the 10 to 15 key design choices, each a short paragraph. +Then, for a reader who stops early: + - Write headings that state the point, not the topic ("Labels compute at + boot, off the critical path", not "Labels"). + - Keep rationale in prose; do not bulletize an argument. Enumerate only + parallel items (decisions, constraints, options). + - State the evidence before the value word: never "fast" before the number. + - Where a choice resolved a real fork, name the alternative and why it lost. + - Order by importance; put a dependency first only where the reader needs it + to follow what comes next, so cutting from the bottom never removes the core. + - Add no YAML frontmatter. Close with one italic line naming the date and the + model. Name no tool, rulebook, or source document for the document's own + rules or structure. + +CHECK BEFORE FINISHING, and fix any no: no implementation code, and every +normative artifact expresses a contract rather than a technique; every element +states what, how, and why; headings state points; no argument is bulletized; +the compression ratio is healthy; no source document is named. If the plan +carries no key design choices, write no document and return the reason. \ No newline at end of file diff --git a/vibe/2026-08-02-2-mcp-server.md b/vibe/2026-08/2026-08-02-2-mcp-server.md similarity index 100% rename from vibe/2026-08-02-2-mcp-server.md rename to vibe/2026-08/2026-08-02-2-mcp-server.md diff --git a/vibe/2026-08-03-1-mcp-server-correction.md b/vibe/2026-08/2026-08-03-1-mcp-server-correction.md similarity index 100% rename from vibe/2026-08-03-1-mcp-server-correction.md rename to vibe/2026-08/2026-08-03-1-mcp-server-correction.md diff --git a/vibe/2026-08-04-1-recover-core-design-rationale.md b/vibe/2026-08/2026-08-04-1-recover-core-design-rationale.md similarity index 100% rename from vibe/2026-08-04-1-recover-core-design-rationale.md rename to vibe/2026-08/2026-08-04-1-recover-core-design-rationale.md diff --git a/vibe/2026-08-05-1-section-lua-lifecycle.md b/vibe/2026-08/2026-08-05-1-section-lua-lifecycle.md similarity index 100% rename from vibe/2026-08-05-1-section-lua-lifecycle.md rename to vibe/2026-08/2026-08-05-1-section-lua-lifecycle.md diff --git a/vibe/2026-08-06-1-prompt-fixtures-logging.md b/vibe/2026-08/2026-08-06-1-prompt-fixtures-logging.md similarity index 100% rename from vibe/2026-08-06-1-prompt-fixtures-logging.md rename to vibe/2026-08/2026-08-06-1-prompt-fixtures-logging.md diff --git a/vibe/2026-08-07-1-dev-loop.md b/vibe/2026-08/2026-08-07-1-dev-loop.md similarity index 100% rename from vibe/2026-08-07-1-dev-loop.md rename to vibe/2026-08/2026-08-07-1-dev-loop.md diff --git a/vibe/2026-08-07-2-store-type-rename.md b/vibe/2026-08/2026-08-07-2-store-type-rename.md similarity index 100% rename from vibe/2026-08-07-2-store-type-rename.md rename to vibe/2026-08/2026-08-07-2-store-type-rename.md diff --git a/vibe/2026-08-07-3-models-debug-cluster.md b/vibe/2026-08/2026-08-07-3-models-debug-cluster.md similarity index 100% rename from vibe/2026-08-07-3-models-debug-cluster.md rename to vibe/2026-08/2026-08-07-3-models-debug-cluster.md diff --git a/vibe/2026-08-07-4-completion-normalize-layer.md b/vibe/2026-08/2026-08-07-4-completion-normalize-layer.md similarity index 100% rename from vibe/2026-08-07-4-completion-normalize-layer.md rename to vibe/2026-08/2026-08-07-4-completion-normalize-layer.md diff --git a/vibe/2026-08-07-5-store-and-fanout.md b/vibe/2026-08/2026-08-07-5-store-and-fanout.md similarity index 100% rename from vibe/2026-08-07-5-store-and-fanout.md rename to vibe/2026-08/2026-08-07-5-store-and-fanout.md diff --git a/vibe/2026-08-08-1-models-always.md b/vibe/2026-08/2026-08-08-1-models-always.md similarity index 100% rename from vibe/2026-08-08-1-models-always.md rename to vibe/2026-08/2026-08-08-1-models-always.md diff --git a/vibe/2026-08-08-10-fanout-gateway-concurrency.md b/vibe/2026-08/2026-08-08-10-fanout-gateway-concurrency.md similarity index 100% rename from vibe/2026-08-08-10-fanout-gateway-concurrency.md rename to vibe/2026-08/2026-08-08-10-fanout-gateway-concurrency.md diff --git a/vibe/2026-08-08-11-briefer-evidence-thickening.md b/vibe/2026-08/2026-08-08-11-briefer-evidence-thickening.md similarity index 100% rename from vibe/2026-08-08-11-briefer-evidence-thickening.md rename to vibe/2026-08/2026-08-08-11-briefer-evidence-thickening.md diff --git a/vibe/2026-08-08-12-supervise-local-llama-server.md b/vibe/2026-08/2026-08-08-12-supervise-local-llama-server.md similarity index 100% rename from vibe/2026-08-08-12-supervise-local-llama-server.md rename to vibe/2026-08/2026-08-08-12-supervise-local-llama-server.md diff --git a/vibe/2026-08-08-2-webfetch-soft-errors.md b/vibe/2026-08/2026-08-08-2-webfetch-soft-errors.md similarity index 100% rename from vibe/2026-08-08-2-webfetch-soft-errors.md rename to vibe/2026-08/2026-08-08-2-webfetch-soft-errors.md diff --git a/vibe/2026-08-08-3-gateway-local-inference.md b/vibe/2026-08/2026-08-08-3-gateway-local-inference.md similarity index 100% rename from vibe/2026-08-08-3-gateway-local-inference.md rename to vibe/2026-08/2026-08-08-3-gateway-local-inference.md diff --git a/vibe/2026-08-08-4-add-sys-model.md b/vibe/2026-08/2026-08-08-4-add-sys-model.md similarity index 100% rename from vibe/2026-08-08-4-add-sys-model.md rename to vibe/2026-08/2026-08-08-4-add-sys-model.md diff --git a/vibe/2026-08-08-5-extract-dev-crate.md b/vibe/2026-08/2026-08-08-5-extract-dev-crate.md similarity index 100% rename from vibe/2026-08-08-5-extract-dev-crate.md rename to vibe/2026-08/2026-08-08-5-extract-dev-crate.md diff --git a/vibe/2026-08-08-6-local-toml-bigger-model.md b/vibe/2026-08/2026-08-08-6-local-toml-bigger-model.md similarity index 100% rename from vibe/2026-08-08-6-local-toml-bigger-model.md rename to vibe/2026-08/2026-08-08-6-local-toml-bigger-model.md diff --git a/vibe/2026-08-08-7-gateway-download-progress.md b/vibe/2026-08/2026-08-08-7-gateway-download-progress.md similarity index 100% rename from vibe/2026-08-08-7-gateway-download-progress.md rename to vibe/2026-08/2026-08-08-7-gateway-download-progress.md diff --git a/vibe/2026-08-08-8-tool-dialect-plugins.md b/vibe/2026-08/2026-08-08-8-tool-dialect-plugins.md similarity index 99% rename from vibe/2026-08-08-8-tool-dialect-plugins.md rename to vibe/2026-08/2026-08-08-8-tool-dialect-plugins.md index 82d4f3d7..f28673e9 100644 --- a/vibe/2026-08-08-8-tool-dialect-plugins.md +++ b/vibe/2026-08/2026-08-08-8-tool-dialect-plugins.md @@ -271,4 +271,4 @@ Neither transcript holds any design discussion of this plan. The chats contain n The presumed creator chat ([TTS and PromptForge Dynamic Addons](586734c2-50a8-43fc-912e-054bcec4d0dd)) did not create this plan. It only read the plan file once, on 2026-08-28, to answer the user's question "is there a plan for the plugins" - and ruled it out: the assistant confirmed the tool_dialect_plugins plans are "about gateway tool-call dialects (OpenAI/Gemma formats, completed work, unrelated)" to the proprietary addon-DLL design that chat actually produced (a separate plan, `addon_dll_abi`). No rationale, why, or discarded alternatives for the dialect plan appear anywhere in it. -The context chat ([find the chat where we talk about promptforge plugins](25a3d0d4-9254-432a-860c-6733e66b3282)) is a transcript-search session; it adds only one attribution hint: the plan was "referenced during the commit rewrite scan" in a third chat (76c7cb24-43d1-4da3-9cc2-ae31fe690711, PromptForge History Rewrite), which may be a better candidate for provenance. The plan's true design discussion likely lives in an earlier, unlinked chat; the plan file itself is the authoritative record of its rationale. +The context chat ([find the chat where we talk about promptforge plugins](25a3d0d4-9254-432a-860c-6733e66b3282)) is a transcript-search session; it adds only one attribution hint: the plan was "referenced during the commit rewrite scan" in a third chat (76c7cb24-43d1-4da3-9cc2-ae31fe690711, PromptForge History Rewrite), which may be a better candidate for provenance. The plan's true design discussion likely lives in an earlier, unlinked chat; the plan file itself is the authoritative record of its rationale. diff --git a/vibe/2026-08-08-9-write-through-store-traces.md b/vibe/2026-08/2026-08-08-9-write-through-store-traces.md similarity index 100% rename from vibe/2026-08-08-9-write-through-store-traces.md rename to vibe/2026-08/2026-08-08-9-write-through-store-traces.md diff --git a/vibe/2026-08-09-1-gateway-web-search-upgrade.md b/vibe/2026-08/2026-08-09-1-gateway-web-search-upgrade.md similarity index 100% rename from vibe/2026-08-09-1-gateway-web-search-upgrade.md rename to vibe/2026-08/2026-08-09-1-gateway-web-search-upgrade.md diff --git a/vibe/2026-08-09-2-promptforge-user-guide.md b/vibe/2026-08/2026-08-09-2-promptforge-user-guide.md similarity index 100% rename from vibe/2026-08-09-2-promptforge-user-guide.md rename to vibe/2026-08/2026-08-09-2-promptforge-user-guide.md diff --git a/vibe/2026-08-09-3-readme-and-ci-overhaul.md b/vibe/2026-08/2026-08-09-3-readme-and-ci-overhaul.md similarity index 100% rename from vibe/2026-08-09-3-readme-and-ci-overhaul.md rename to vibe/2026-08/2026-08-09-3-readme-and-ci-overhaul.md diff --git a/vibe/2026-08-09-4-store-real-plus-virtual.md b/vibe/2026-08/2026-08-09-4-store-real-plus-virtual.md similarity index 100% rename from vibe/2026-08-09-4-store-real-plus-virtual.md rename to vibe/2026-08/2026-08-09-4-store-real-plus-virtual.md diff --git a/vibe/2026-08-09-5-h1-once-no-replay.md b/vibe/2026-08/2026-08-09-5-h1-once-no-replay.md similarity index 98% rename from vibe/2026-08-09-5-h1-once-no-replay.md rename to vibe/2026-08/2026-08-09-5-h1-once-no-replay.md index 2b6111ec..fd0bf84d 100644 --- a/vibe/2026-08-09-5-h1-once-no-replay.md +++ b/vibe/2026-08/2026-08-09-5-h1-once-no-replay.md @@ -1,90 +1,90 @@ ---- -name: H1 once no replay -overview: "Runtime refactor shipped: bind phase eliminated, H1 runs once with live resolution, lua shared replays before host inject. Plan complete except two doc gaps identified on rescan." -todos: - - id: doc-quick-reference - content: "Add Quick Reference rules block to the user guide (before final image)" - status: pending - - id: doc-status-orig-claim - content: "Remove stale design-core-orig.md claim from STATUS.md (file was deleted)" - status: pending -isProject: false ---- - -# Single-pass H1: live resolution, no bind phase - -## Status: SHIPPED - -All runtime steps completed and merged to master through 0.1.0 release prep. The architecture described below is the current production state of the repository as of `7661b38` (tip). - -## What shipped (verified on rescan 2026-08-13) - -- `bind.rs` deleted; replaced by `resolve.rs` (live runtime resolution) -- `BoundPrompt` gone from entire codebase -- No `install_replay_tools`, `install_replay_models`, `ToolPhase::Replay`, `ModelPhase::Replay` -- `Prompt` struct carries `replay: Option` and `h1_blocks: Vec` -- `execute::run` takes `&Prompt` + `ResolutionContext` directly -- `lua shared` fence recognized in parser; exactly one allowed, H1 only -- 504 promptforge-core lib tests pass -- CLI, dev, MCP all use parse-to-run path -- `design-core-orig.md` was deleted (consolidated into `design-core.md`) - -## Remaining gaps (two documentation items) - -### 1. Quick Reference missing from user guide - -The plan called for a compact **Quick Reference** section at the end of the user guide (before the final robot image) with these rules: - -- Non-final prose blocks: single-shot. One model round (may include tool calls for that round). Control moves to the next lua block after the model responds. Conversation accumulates. -- Final prose block: full tool loop. Model keeps calling tools until it produces text. That text becomes `reply`. -- Lua blocks: run sequentially. Can mutate tool scope (`tools.add`), write to store, inspect `reply`, call `execute()` or `jump()`, call `model:infer()` explicitly. -- One conversation per section. Context grows across all blocks within the section. Cleared between sections. -- Sections are subroutines. `execute("## Name", input?)` runs a section in a fresh VM, full tool loop, returns its reply. Like fanout but sequential and single. -- `jump("## Name")` transfers control. Context clears. The current section stops. The named section runs next. No return to caller. - -Plus the block sequence diagram: - -``` -[lua] [prose] [lua] [prose] ... [lua] -``` - -This was never added. Needs to go into the current monolithic `promptforge-user-guide.md` or the mdbook equivalent. - -### 2. STATUS.md stale claim - -STATUS.md still says "`design-core-orig.md` remains byte-identical history." That file was deleted in commit `2d00ded` during the design consolidation. The claim must be removed or updated to reflect that `design-core.md` is now the sole authoritative design document. - -## Architecture (reference, no longer actionable) - -``` -Parse -> Execute H1 live (resolvers fire, infer OK, store OK) -> Sections (Rust-installed bindings + library replay) -``` - -```mermaid -flowchart TD - parse["Parse: compile all blocks + library to bytecode"] - h1["Execute H1 live once:
args, sys, store, infer
tools.need resolves via picker
models.need resolves via catalog"] - sec["Each section VM:
1. Load Prompt.replay before host inject
2. Install frozen bindings from Rust
3. inject_host
4. Run section blocks"] - parse --> h1 --> sec -``` - -## Key decisions (historical record) - -| Decision | Choice | -|---|---| -| Bind phase | Eliminated | -| `BoundPrompt` type | Removed | -| Declaration mode / replay mode | Removed | -| H1 execution | Once, live, full host access | -| `tools.need` / `models.need` | Runtime resolution via picker; return frozen Tool/Model objects | -| Section VM binding install | Rust installs from frozen maps | -| Library (`lua shared`) | Loaded per section BEFORE host inject; pure function defs only at load time | -| `var` from H1 | Serialized; seeds each section's initial `var` | -| `store` from H1 | Persists naturally (run-scoped) | -| Conditional declarations | Natural (tools.need inside if-blocks, after infer) | -| Near-duplicate validation | At scope-close | -| `design-core-orig.md` | Deleted during consolidation (was preserved during initial plan) | - -## Research (retained for reference) - -VM clone research confirmed no stock Lua 5.4 / mlua state clone exists. Host-ID-registry snapshot (Eris-style, in Rust) remains a viable future design for richer state sharing. Current approach (explicit `lua shared` replay + frozen bindings from Rust) is the pragmatic working solution. +--- +name: H1 once no replay +overview: "Runtime refactor shipped: bind phase eliminated, H1 runs once with live resolution, lua shared replays before host inject. Plan complete except two doc gaps identified on rescan." +todos: + - id: doc-quick-reference + content: "Add Quick Reference rules block to the user guide (before final image)" + status: pending + - id: doc-status-orig-claim + content: "Remove stale design-core-orig.md claim from STATUS.md (file was deleted)" + status: pending +isProject: false +--- + +# Single-pass H1: live resolution, no bind phase + +## Status: SHIPPED + +All runtime steps completed and merged to master through 0.1.0 release prep. The architecture described below is the current production state of the repository as of `7661b38` (tip). + +## What shipped (verified on rescan 2026-08-13) + +- `bind.rs` deleted; replaced by `resolve.rs` (live runtime resolution) +- `BoundPrompt` gone from entire codebase +- No `install_replay_tools`, `install_replay_models`, `ToolPhase::Replay`, `ModelPhase::Replay` +- `Prompt` struct carries `replay: Option` and `h1_blocks: Vec` +- `execute::run` takes `&Prompt` + `ResolutionContext` directly +- `lua shared` fence recognized in parser; exactly one allowed, H1 only +- 504 promptforge-core lib tests pass +- CLI, dev, MCP all use parse-to-run path +- `design-core-orig.md` was deleted (consolidated into `design-core.md`) + +## Remaining gaps (two documentation items) + +### 1. Quick Reference missing from user guide + +The plan called for a compact **Quick Reference** section at the end of the user guide (before the final robot image) with these rules: + +- Non-final prose blocks: single-shot. One model round (may include tool calls for that round). Control moves to the next lua block after the model responds. Conversation accumulates. +- Final prose block: full tool loop. Model keeps calling tools until it produces text. That text becomes `reply`. +- Lua blocks: run sequentially. Can mutate tool scope (`tools.add`), write to store, inspect `reply`, call `execute()` or `jump()`, call `model:infer()` explicitly. +- One conversation per section. Context grows across all blocks within the section. Cleared between sections. +- Sections are subroutines. `execute("## Name", input?)` runs a section in a fresh VM, full tool loop, returns its reply. Like fanout but sequential and single. +- `jump("## Name")` transfers control. Context clears. The current section stops. The named section runs next. No return to caller. + +Plus the block sequence diagram: + +``` +[lua] [prose] [lua] [prose] ... [lua] +``` + +This was never added. Needs to go into the current monolithic `promptforge-user-guide.md` or the mdbook equivalent. + +### 2. STATUS.md stale claim + +STATUS.md still says "`design-core-orig.md` remains byte-identical history." That file was deleted in commit `2d00ded` during the design consolidation. The claim must be removed or updated to reflect that `design-core.md` is now the sole authoritative design document. + +## Architecture (reference, no longer actionable) + +``` +Parse -> Execute H1 live (resolvers fire, infer OK, store OK) -> Sections (Rust-installed bindings + library replay) +``` + +```mermaid +flowchart TD + parse["Parse: compile all blocks + library to bytecode"] + h1["Execute H1 live once:
args, sys, store, infer
tools.need resolves via picker
models.need resolves via catalog"] + sec["Each section VM:
1. Load Prompt.replay before host inject
2. Install frozen bindings from Rust
3. inject_host
4. Run section blocks"] + parse --> h1 --> sec +``` + +## Key decisions (historical record) + +| Decision | Choice | +|---|---| +| Bind phase | Eliminated | +| `BoundPrompt` type | Removed | +| Declaration mode / replay mode | Removed | +| H1 execution | Once, live, full host access | +| `tools.need` / `models.need` | Runtime resolution via picker; return frozen Tool/Model objects | +| Section VM binding install | Rust installs from frozen maps | +| Library (`lua shared`) | Loaded per section BEFORE host inject; pure function defs only at load time | +| `var` from H1 | Serialized; seeds each section's initial `var` | +| `store` from H1 | Persists naturally (run-scoped) | +| Conditional declarations | Natural (tools.need inside if-blocks, after infer) | +| Near-duplicate validation | At scope-close | +| `design-core-orig.md` | Deleted during consolidation (was preserved during initial plan) | + +## Research (retained for reference) + +VM clone research confirmed no stock Lua 5.4 / mlua state clone exists. Host-ID-registry snapshot (Eris-style, in Rust) remains a viable future design for richer state sharing. Current approach (explicit `lua shared` replay + frozen bindings from Rust) is the pragmatic working solution. diff --git a/vibe/2026-08-10-1-crate-review.md b/vibe/2026-08/2026-08-10-1-crate-review.md similarity index 100% rename from vibe/2026-08-10-1-crate-review.md rename to vibe/2026-08/2026-08-10-1-crate-review.md diff --git a/vibe/2026-08-11-1-dokuman-all-crates.md b/vibe/2026-08/2026-08-11-1-dokuman-all-crates.md similarity index 100% rename from vibe/2026-08-11-1-dokuman-all-crates.md rename to vibe/2026-08/2026-08-11-1-dokuman-all-crates.md diff --git a/vibe/2026-08-11-2-make-user-guide-crate.md b/vibe/2026-08/2026-08-11-2-make-user-guide-crate.md similarity index 100% rename from vibe/2026-08-11-2-make-user-guide-crate.md rename to vibe/2026-08/2026-08-11-2-make-user-guide-crate.md diff --git a/vibe/2026-08-12-1-mdbook-guide.md b/vibe/2026-08/2026-08-12-1-mdbook-guide.md similarity index 100% rename from vibe/2026-08-12-1-mdbook-guide.md rename to vibe/2026-08/2026-08-12-1-mdbook-guide.md diff --git a/vibe/2026-08-14-1-file-backed-store.md b/vibe/2026-08/2026-08-14-1-file-backed-store.md similarity index 100% rename from vibe/2026-08-14-1-file-backed-store.md rename to vibe/2026-08/2026-08-14-1-file-backed-store.md diff --git a/vibe/2026-08-15-1-store-input-files.md b/vibe/2026-08/2026-08-15-1-store-input-files.md similarity index 100% rename from vibe/2026-08-15-1-store-input-files.md rename to vibe/2026-08/2026-08-15-1-store-input-files.md diff --git a/vibe/2026-08-15-2-quickref-file.md b/vibe/2026-08/2026-08-15-2-quickref-file.md similarity index 100% rename from vibe/2026-08-15-2-quickref-file.md rename to vibe/2026-08/2026-08-15-2-quickref-file.md diff --git a/vibe/2026-08-15-3-gateway-dotenv-support.md b/vibe/2026-08/2026-08-15-3-gateway-dotenv-support.md similarity index 100% rename from vibe/2026-08-15-3-gateway-dotenv-support.md rename to vibe/2026-08/2026-08-15-3-gateway-dotenv-support.md diff --git a/vibe/2026-08-15-4-h1-only-prompt-support.md b/vibe/2026-08/2026-08-15-4-h1-only-prompt-support.md similarity index 100% rename from vibe/2026-08-15-4-h1-only-prompt-support.md rename to vibe/2026-08/2026-08-15-4-h1-only-prompt-support.md diff --git a/vibe/2026-08-16-1-fanout-scope-refactor.md b/vibe/2026-08/2026-08-16-1-fanout-scope-refactor.md similarity index 100% rename from vibe/2026-08-16-1-fanout-scope-refactor.md rename to vibe/2026-08/2026-08-16-1-fanout-scope-refactor.md diff --git a/vibe/2026-08-18-1-tools-local.md b/vibe/2026-08/2026-08-18-1-tools-local.md similarity index 100% rename from vibe/2026-08-18-1-tools-local.md rename to vibe/2026-08/2026-08-18-1-tools-local.md diff --git a/vibe/2026-08-18-2-empty-turn-and-mcp-env.md b/vibe/2026-08/2026-08-18-2-empty-turn-and-mcp-env.md similarity index 100% rename from vibe/2026-08-18-2-empty-turn-and-mcp-env.md rename to vibe/2026-08/2026-08-18-2-empty-turn-and-mcp-env.md diff --git a/vibe/2026-08-18-3-untrusted-global-vm-reorder.md b/vibe/2026-08/2026-08-18-3-untrusted-global-vm-reorder.md similarity index 100% rename from vibe/2026-08-18-3-untrusted-global-vm-reorder.md rename to vibe/2026-08/2026-08-18-3-untrusted-global-vm-reorder.md diff --git a/vibe/2026-08-18-4-fanout-collection-items.md b/vibe/2026-08/2026-08-18-4-fanout-collection-items.md similarity index 100% rename from vibe/2026-08-18-4-fanout-collection-items.md rename to vibe/2026-08/2026-08-18-4-fanout-collection-items.md diff --git a/vibe/2026-08-19-1-collapse-fanout-arm-engine.md b/vibe/2026-08/2026-08-19-1-collapse-fanout-arm-engine.md similarity index 100% rename from vibe/2026-08-19-1-collapse-fanout-arm-engine.md rename to vibe/2026-08/2026-08-19-1-collapse-fanout-arm-engine.md diff --git a/vibe/2026-08-19-2-promptforge-refactor.md b/vibe/2026-08/2026-08-19-2-promptforge-refactor.md similarity index 100% rename from vibe/2026-08-19-2-promptforge-refactor.md rename to vibe/2026-08/2026-08-19-2-promptforge-refactor.md diff --git a/vibe/2026-08-20-1-fix-core-review-findings.md b/vibe/2026-08/2026-08-20-1-fix-core-review-findings.md similarity index 100% rename from vibe/2026-08-20-1-fix-core-review-findings.md rename to vibe/2026-08/2026-08-20-1-fix-core-review-findings.md diff --git a/vibe/2026-08-22-1-runcontext-seed.md b/vibe/2026-08/2026-08-22-1-runcontext-seed.md similarity index 100% rename from vibe/2026-08-22-1-runcontext-seed.md rename to vibe/2026-08/2026-08-22-1-runcontext-seed.md diff --git a/vibe/2026-08-23-1-dominion-refactor.md b/vibe/2026-08/2026-08-23-1-dominion-refactor.md similarity index 99% rename from vibe/2026-08-23-1-dominion-refactor.md rename to vibe/2026-08/2026-08-23-1-dominion-refactor.md index 9c29fb46..0031842b 100644 --- a/vibe/2026-08-23-1-dominion-refactor.md +++ b/vibe/2026-08/2026-08-23-1-dominion-refactor.md @@ -514,4 +514,4 @@ Verbatim: "The goal is zero dialects. We're gonna normalize whatever is on the o - Housekeeping deviations: the four research files survived only after the user asked "are those 4 research files any use? commit them if they are truly useful otherwise delete"; a recover-rationale mechanism was removed on direct order ("I want that recover-rationale thingy deleted"). - Post-execution reflection: 37 commits with roughly 5k net lines added, and the user openly asked "was it worth it? 5k net increase in lines" - the net growth is on record as questioned, not celebrated. - From the third run chat (mostly an unrelated skillgate session): a downstream motivation for streaming plus thinking normalization - PromptForge Workbench must distinguish thinking from reply blocks, and "my idea was to have the gateway normalize always, so every client gets a clean stream with distinguished blocks". -- The second run chat (step-5 coder subagent) holds no deviations; it confirms steps 1-4 landed as planned. +- The second run chat (step-5 coder subagent) holds no deviations; it confirms steps 1-4 landed as planned. diff --git a/vibe/2026-08-23-2-gateway-phases-4-5.md b/vibe/2026-08/2026-08-23-2-gateway-phases-4-5.md similarity index 99% rename from vibe/2026-08-23-2-gateway-phases-4-5.md rename to vibe/2026-08/2026-08-23-2-gateway-phases-4-5.md index dc4aae9b..a5d88bcd 100644 --- a/vibe/2026-08-23-2-gateway-phases-4-5.md +++ b/vibe/2026-08/2026-08-23-2-gateway-phases-4-5.md @@ -228,7 +228,7 @@ Checking the code settled it: core has exactly two dialects, OpenAi (identity pa ## Semantic blur and the entropy rules -The user demanded a rewrite: "I want it going in clean, no extra unnecessary shit, I do not want blurbloat," then clarified the lesson applies to code, not just the plan document: "the point of semantic blur is that when you edit the code I dont want you to bloat it. I want you to make the edits in a way that reverses entropy." He asked for "up to 20 unambiguous ways that entropy can be reversed," probed the hardest corner case himself ("what about combining two structs that are *almost* always used together" - the answer baked into rule 21: merge only when the exception case can hold a fully valid value; placeholder construction is entropy in a new costume), then directed: "bake the rules into the plan as an xml-delimited section that the subagent will refer to," with the grep-only dispatch instruction. This is the origin of the plan's entropy-rules block and its grep-dispatch pattern. +The user demanded a rewrite: "I want it going in clean, no extra unnecessary shit, I do not want blurbloat," then clarified the lesson applies to code, not just the plan document: "the point of semantic blur is that when you edit the code I dont want you to bloat it. I want you to make the edits in a way that reverses entropy." He asked for "up to 20 unambiguous ways that entropy can be reversed," probed the hardest corner case himself ("what about combining two structs that are *almost* always used together" - the answer baked into rule 21: merge only when the exception case can hold a fully valid value; placeholder construction is entropy in a new costume), then directed: "bake the rules into the plan as an xml-delimited section that the subagent will refer to," with the grep-only dispatch instruction. This is the origin of the plan's entropy-rules block and its grep-dispatch pattern. ## litellm-rust comparison @@ -245,4 +245,4 @@ The final review caught that step 24 as drafted implied deleting normalize.rs as ## Run chat -The designated run chat (59952dc1) contains no execution of this plan and no deviations - it is an unrelated transcript-processing session, so it contributes nothing here. +The designated run chat (59952dc1) contains no execution of this plan and no deviations - it is an unrelated transcript-processing session, so it contributes nothing here. diff --git a/vibe/2026-08-24-1-stage-1-the-window.md b/vibe/2026-08/2026-08-24-1-stage-1-the-window.md similarity index 99% rename from vibe/2026-08-24-1-stage-1-the-window.md rename to vibe/2026-08/2026-08-24-1-stage-1-the-window.md index d9f65b6a..feb82a38 100644 --- a/vibe/2026-08-24-1-stage-1-the-window.md +++ b/vibe/2026-08/2026-08-24-1-stage-1-the-window.md @@ -114,7 +114,7 @@ The tape's rationale is the session's central thesis. The user: "the prompts the - **VS Code / Cursor extension as the first deliverable**: considered ("I don't think I mind being locked to Cursor... 80% of that proprietary extension will be reusable webshit"), then inverted by the user into the two-crate split the plan adopts: "we could develop this as two crates. One is the web server... And then the second crate is a front end executable... so I could develop this completely standalone, make it work, make it awesome, and then when I'm ready, then integrate it into the Cursor, and then we don't have to solve the problem of the Cursor integration, and other people can use it." - **Voice through the gateway**: rejected by the user: "can we just keep it all in the server, run the speech to text on the local video card and not involve the gateway?" On the objection that in-process whisper risks crash isolation: "I dont care about the crash isolation. If there's a bug, they can fix it." - **Latency-first transcription**: initially the user asked for "the best: lowest latency, most accurate," then reversed: "I changed my mind. Low latency is important but it is more important to get the transcription right, early on, and so the user can see the words forming and correcting." The pipelined final pass (plan step 8) is the user's own design: "why don't we, in parallel, also run the large-v3 once we have 10 seconds worth, so that when the user stops, then on average they are only waiting for large-v3 to process 5 seconds of audio?" -- **Runtime model download in stage 1**: the user wanted the gateway to own model storage ("cache this model", "list cached models", SSE download progress), because the gateway already configures the model directory and "I don't wanna start duplicating all this code everywhere." This was deferred; the plan settles for out-of-band downloads documented in the README. +- **Runtime model download in stage 1**: the user wanted the gateway to own model storage ("cache this model", "list cached models", SSE download progress), because the gateway already configures the model directory and "I don't wanna start duplicating all this code everywhere." This was deferred; the plan settles for out-of-band downloads documented in the README. ## How the stage boundaries were drawn @@ -131,4 +131,4 @@ That exclusion was hard-won. The user personally attacked replay/caching as unso ## After the run (context for later plans) -Stage 1 was declared done by the user: "it's done. We have that... it works, the microphone works, it's beautiful." During and after the run he reversed two things this plan specifies, both belonging to follow-up work: config moved from env vars to a user-directory TOML with defaults ("can you imagine if you install Photoshop.exe and you have to set some env vars first in order to run it? no way"), and SSE-plus-vanilla-JS was later questioned in favor of two WebSocket connections and a murm-ui/dockview/TypeScript UI. The plan file still reflects the original SSE and vanilla-DOM choices. +Stage 1 was declared done by the user: "it's done. We have that... it works, the microphone works, it's beautiful." During and after the run he reversed two things this plan specifies, both belonging to follow-up work: config moved from env vars to a user-directory TOML with defaults ("can you imagine if you install Photoshop.exe and you have to set some env vars first in order to run it? no way"), and SSE-plus-vanilla-JS was later questioned in favor of two WebSocket connections and a murm-ui/dockview/TypeScript UI. The plan file still reflects the original SSE and vanilla-DOM choices. diff --git a/vibe/2026-08-24-2-autogen-config-first-run.md b/vibe/2026-08/2026-08-24-2-autogen-config-first-run.md similarity index 99% rename from vibe/2026-08-24-2-autogen-config-first-run.md rename to vibe/2026-08/2026-08-24-2-autogen-config-first-run.md index 550d01e3..8ff3af8a 100644 --- a/vibe/2026-08-24-2-autogen-config-first-run.md +++ b/vibe/2026-08/2026-08-24-2-autogen-config-first-run.md @@ -374,4 +374,4 @@ This is the PromptForge workbench stage-2 plan, created 2026-08-24 in the creato - **GPU was an unstated assumption.** Whisper ran on CPU until CUDA was installed: "wait what? Why are we using CPU? This is supposed to use GPU ! JEsus". - **The interim/final window transcription proved too volatile in live dictation** - text visibly shrank and reappeared on stop. This spun off a follow-on progressive-transcription design (stable crystallized prefix via speculative large-model passes, volatile tail), with a latch because "we need to make sure that we are never trying to do 2 final passes at the same time". - **Process deviations.** For plan edits the user overrode the one-commit-per-step rule: "I want just one commit so make all the plan changes in once"; and later "run the plan without fuss. no subagents." -- **CI needed fixture gating.** Whisper-dependent tests failed in CI where model fixtures were absent; they were marked ignored, and one reconnect test was flaky under its deadline. +- **CI needed fixture gating.** Whisper-dependent tests failed in CI where model fixtures were absent; they were marked ignored, and one reconnect test was flaky under its deadline. diff --git a/vibe/2026-08-24-3-voice-ux-fixes.md b/vibe/2026-08/2026-08-24-3-voice-ux-fixes.md similarity index 99% rename from vibe/2026-08-24-3-voice-ux-fixes.md rename to vibe/2026-08/2026-08-24-3-voice-ux-fixes.md index 1f0e3278..1a4df010 100644 --- a/vibe/2026-08-24-3-voice-ux-fixes.md +++ b/vibe/2026-08/2026-08-24-3-voice-ux-fixes.md @@ -195,7 +195,7 @@ Recovered from the producing chat sessions by the plan ledger on 2026-09-04. Eve The status bar itself originated earlier the same day (Aug 24, 2026) from the user's demand for ambient visibility into the workbench: "that bar should always show the user like what the fuck the workbench is doing. Like at all times", delivered via an observer object threaded through the program - explicitly "I don't want a global variable, but I want something threaded through the entire program". The reconnect requirement also predates this plan: "the workbench must tolerate losing the connection to the gateway, and reconnecting". -This plan is the same-day correction pass: the user ran the UI, watched it, and reported defects in real time (with screenshots) - clipped descenders, hover styling clobbering the recording state, LED colors that did not match his mental model, and transcribed text landing outside the edit box. The plan bundles those fixes plus the already-uncommitted debugging-session fixes (120s stop timeout, status.idle() on session close) because the user wanted "just one commit". +This plan is the same-day correction pass: the user ran the UI, watched it, and reported defects in real time (with screenshots) - clipped descenders, hover styling clobbering the recording state, LED colors that did not match his mental model, and transcribed text landing outside the edit box. The plan bundles those fixes plus the already-uncommitted debugging-session fixes (120s stop timeout, status.idle() on session close) because the user wanted "just one commit". ## Decisive user statements (verbatim) @@ -205,7 +205,7 @@ This plan is the same-day correction pass: the user ran the UI, watched it, and - Layout: "to be clear I want REC and the LED to be right justified, dont put a huge space between those two. just a normal space like the width of a SP character (or two)"; after seeing the first run: "too much space between REC and the LED. also, the inactive REC is too red. make it like 0x552222". - Hover glow: "when the record button is enabled and I hover, it looks like it's not enabled anymore... I think I want the hover to be to outline the control with the glow. For like everything should be that way". - Transcript auto-grow: "I want it, in the fucking edit box, in a way that the user can select it and edit it afterwards and make sure that all the text is visible (up to some verttical height limit)". -- LED aesthetic (from the morning design talk): "I want that LED to be beautiful with a soft glow like a real LED"; and the slot rule: "the progress bar and the LED are mutually exclusive, the progress bar takes priority". +- LED aesthetic (from the morning design talk): "I want that LED to be beautiful with a soft glow like a real LED"; and the slot rule: "the progress bar and the LED are mutually exclusive, the progress bar takes priority". ## Discarded alternatives @@ -223,4 +223,4 @@ This plan is the same-day correction pass: the user ran the UI, watched it, and 3. A provision.rs test asserted the wire string `"voice"` - fixed to `"general"`; the plan's `Activity::` grep missed string literals. 4. One filtered re-run showed a native CUDA teardown access violation after all tests passed; it did not recur in the full suite and was judged unrelated (enum rename only). -The second supplied run chat (59952dc1) is not a plan run - it is a transcript-mining session that only catalogs this plan - and holds no deviations. +The second supplied run chat (59952dc1) is not a plan run - it is a transcript-mining session that only catalogs this plan - and holds no deviations. diff --git a/vibe/2026-08-24-4-progressive-transcription.md b/vibe/2026-08/2026-08-24-4-progressive-transcription.md similarity index 99% rename from vibe/2026-08-24-4-progressive-transcription.md rename to vibe/2026-08/2026-08-24-4-progressive-transcription.md index a815ff42..2fdd0885 100644 --- a/vibe/2026-08-24-4-progressive-transcription.md +++ b/vibe/2026-08/2026-08-24-4-progressive-transcription.md @@ -133,7 +133,7 @@ Three options were adjudicated: - Option B (discarded): replace whisper-rs with transcribe-cpp. Rejected because it uses a different GGUF format (existing downloaded models unusable), is pre-1.0 with breaking changes between 0.1 and 0.2, is single-maintainer, and has no merged hotwords. Verdict (paraphrase): switching engines to save ~200 lines would cost a week and add risk for no accuracy gain - it is the same Whisper architecture underneath. - Option C (partially absorbed): steal `CommitPolicy::StablePrefix` (N-agreement on consecutive hypotheses). The plan as written did not implement N-agreement; crystallization is instead driven by the energy segmenter's silence gaps, which the pipeline already had. -Vocabulary biasing format: `"Glossary: term1, term2, ..."` was chosen over raw keyword lists because benchmarks showed roughly 50% WER reduction (research claim, paraphrase). It is a soft bias, not a guarantee; post-processing regex for critical terms was suggested as a complement but not planned. +Vocabulary biasing format: `"Glossary: term1, term2, ..."` was chosen over raw keyword lists because benchmarks showed roughly 50% WER reduction (research claim, paraphrase). It is a soft bias, not a guarantee; post-processing regex for critical terms was suggested as a complement but not planned. ## Post-plan design evolution (creator chat, same day) @@ -151,4 +151,4 @@ Run chat 8989e7ea (coder subagent, commit 3 - committed state + interim wire pro - Clippy pressure (pedantic lints promoted to errors by `-D warnings`) forced an `expect(too_many_arguments)` and helper extraction to keep `run_session` under the 100-line limit. - A pre-existing `chat_ws` reconnect-test flake reproduced on the base commit; unrelated to the change. -Run chat 59952dc1: unrelated to this plan (transcript mining / skillgate tooling). No deviations recorded. +Run chat 59952dc1: unrelated to this plan (transcript mining / skillgate tooling). No deviations recorded. diff --git a/vibe/2026-08-24-5-stop-recording-on-send.md b/vibe/2026-08/2026-08-24-5-stop-recording-on-send.md similarity index 99% rename from vibe/2026-08-24-5-stop-recording-on-send.md rename to vibe/2026-08/2026-08-24-5-stop-recording-on-send.md index 352790fb..1e8b5aad 100644 --- a/vibe/2026-08-24-5-stop-recording-on-send.md +++ b/vibe/2026-08/2026-08-24-5-stop-recording-on-send.md @@ -149,4 +149,4 @@ The assistant confirmed this matches OS dictation (Windows Win+H, macOS, Dragon) ## Run-chat deviations -None. The supplied run chat does not execute this plan; it only references the plan file in a transcript inventory. The plan was executed inside the creator chat itself. One deviation from the plan text: the prescribed commit message was "Discard the voice take when a message is sent mid-recording", but the actual commit f2bd202 landed as "Handle voice recording as cursor-position typing with send-discard". Three review findings (dead field, empty-state CSS interaction, multi-take test) were fixed and amended in before the commit. +None. The supplied run chat does not execute this plan; it only references the plan file in a transcript inventory. The plan was executed inside the creator chat itself. One deviation from the plan text: the prescribed commit message was "Discard the voice take when a message is sent mid-recording", but the actual commit f2bd202 landed as "Handle voice recording as cursor-position typing with send-discard". Three review findings (dead field, empty-state CSS interaction, multi-take test) were fixed and amended in before the commit. diff --git a/vibe/2026-08-24-6-ci-and-hygiene-fixes.md b/vibe/2026-08/2026-08-24-6-ci-and-hygiene-fixes.md similarity index 100% rename from vibe/2026-08-24-6-ci-and-hygiene-fixes.md rename to vibe/2026-08/2026-08-24-6-ci-and-hygiene-fixes.md diff --git a/vibe/2026-08-25-1-rename-workbench-to-workshop.md b/vibe/2026-08/2026-08-25-1-rename-workbench-to-workshop.md similarity index 99% rename from vibe/2026-08-25-1-rename-workbench-to-workshop.md rename to vibe/2026-08/2026-08-25-1-rename-workbench-to-workshop.md index 76941783..4361f51d 100644 --- a/vibe/2026-08-25-1-rename-workbench-to-workshop.md +++ b/vibe/2026-08/2026-08-25-1-rename-workbench-to-workshop.md @@ -135,7 +135,7 @@ The plan line "Archives (user chose rewrite everywhere)" records only the outcom ## Why the WebSocket guardrails exist -The user ordered: "search the files and bake the websocket guardrails into the plan". The decisive point (paraphrase): the guardrails are evidence-based, built from actual searches over the repo, not a generic skip list. The core hazard (paraphrase): the new product abbreviation `ws` collides with the pre-existing WebSocket `ws` token, so once the crate is named `promptforge-ws`, any global `\bws\b` cleanup would destroy the chat protocol. `pcm-worklet.js` was caught in the same trap class: "worklet" is not "workbench". +The user ordered: "search the files and bake the websocket guardrails into the plan". The decisive point (paraphrase): the guardrails are evidence-based, built from actual searches over the repo, not a generic skip list. The core hazard (paraphrase): the new product abbreviation `ws` collides with the pre-existing WebSocket `ws` token, so once the crate is named `promptforge-ws`, any global `\bws\b` cleanup would destroy the chat protocol. `pcm-worklet.js` was caught in the same trap class: "worklet" is not "workbench". ## Blast radius reasoning (chat only, not in the plan) @@ -157,4 +157,4 @@ The plan says to git-mv and rewrite `cabinet/_output/architect-promptforge-workb - Nothing was committed until the user later said "git add commit". Result: commit `eafab46` on promptforge `master`, 128 files, not pushed at that time. - Verification that passed: `cargo test --locked -p promptforge-ws -p promptforge-ws-server` (113 server unit tests plus shell tests). - CI nuance (paraphrase): the Ubuntu check job excludes the workshop crates on purpose while the Windows job builds them; a local `cargo build` builds everything because the workspace has no `default-members`. -- wg21-paperflow needed zero changes because it path-depends only on `promptforge-core` and `promptforge-tool-picker`, whose names did not change. +- wg21-paperflow needed zero changes because it path-depends only on `promptforge-core` and `promptforge-tool-picker`, whose names did not change. diff --git a/vibe/2026-08-26-1-remove-voice-status-line.md b/vibe/2026-08/2026-08-26-1-remove-voice-status-line.md similarity index 99% rename from vibe/2026-08-26-1-remove-voice-status-line.md rename to vibe/2026-08/2026-08-26-1-remove-voice-status-line.md index 83faa3fd..57a3fe3e 100644 --- a/vibe/2026-08-26-1-remove-voice-status-line.md +++ b/vibe/2026-08/2026-08-26-1-remove-voice-status-line.md @@ -283,4 +283,4 @@ The run transcript covers only the step-13 coder subagent. Deviations from the p - A deferred-use ESM import cycle (zones -> panel-types -> workshop-panel -> zones) was accepted deliberately after analysis showed all cross-uses are runtime-deferred; the `ZoneName` type was homed in `panel-types.ts` to eliminate the type-level cycle. - `createComponent` returns a placeholder renderer for unknown panel names instead of throwing, to avoid breaking Dockview on a bad name. -Process note: the subagent's shell session wedged on a piped command mid-task; verification was completed in fresh shell sessions. No plan content impact. +Process note: the subagent's shell session wedged on a piped command mid-task; verification was completed in fresh shell sessions. No plan content impact. diff --git a/vibe/2026-08-26-2-model-turn-actions.md b/vibe/2026-08/2026-08-26-2-model-turn-actions.md similarity index 99% rename from vibe/2026-08-26-2-model-turn-actions.md rename to vibe/2026-08/2026-08-26-2-model-turn-actions.md index fc4a954c..f61a0cca 100644 --- a/vibe/2026-08-26-2-model-turn-actions.md +++ b/vibe/2026-08/2026-08-26-2-model-turn-actions.md @@ -124,7 +124,7 @@ Model menu, zone order, and lock removal, all in one decisive message: Agent tabs: -> "docked windows need tabs, I didn't see any tab for the Agent window. can we add a menu item for New Agent?" +> "docked windows need tabs, I didn't see any tab for the Agent window. can we add a menu item for New Agent?" ## Discarded alternatives @@ -139,4 +139,4 @@ Agent tabs: - Cursor is the explicit reference implementation for the turn footer, the thinking block, and the dark rounded tooltips; the plan's phrase "matching the supplied Cursor reference" refers to screenshots the user pasted during the session. - Zone affinity comes from the user's panels vision stated earlier that day: "each zone has a preference. Like the agent zone prefers to have agent windows, the document zone prefers to have documents. So whenever a new document is created, it goes to the zone where it has affinity." The plan preserves this as the affinity mapping while making the default boot order Workspace-left, Editor-middle, Agent-right explicit. - Modularity was a standing requirement from the same conversation: "I want each piece of code to be very well isolated, especially the TypeScript... so that I can swap pieces out" (paraphrase of a longer dictated passage). This motivates the per-Agent `ChatUI` controller with isolated plugin lifecycles per tab and only the socket provider and model state shared. -- The plan's restraint on `noUncheckedIndexedAccess` reflects a prior audit finding unrelated pre-existing failures; the user wanted the TypeScript configuration kept stable during feature work (paraphrase). +- The plan's restraint on `noUncheckedIndexedAccess` reflects a prior audit finding unrelated pre-existing failures; the user wanted the TypeScript configuration kept stable during feature work (paraphrase). diff --git a/vibe/2026-08-26-3-workshop-regression-fixes.md b/vibe/2026-08/2026-08-26-3-workshop-regression-fixes.md similarity index 100% rename from vibe/2026-08-26-3-workshop-regression-fixes.md rename to vibe/2026-08/2026-08-26-3-workshop-regression-fixes.md diff --git a/vibe/2026-08-26-4-workshop-idiom-refactor.md b/vibe/2026-08/2026-08-26-4-workshop-idiom-refactor.md similarity index 99% rename from vibe/2026-08-26-4-workshop-idiom-refactor.md rename to vibe/2026-08/2026-08-26-4-workshop-idiom-refactor.md index b399686c..4a1b706a 100644 --- a/vibe/2026-08-26-4-workshop-idiom-refactor.md +++ b/vibe/2026-08/2026-08-26-4-workshop-idiom-refactor.md @@ -267,4 +267,4 @@ The idiom report's in-repo location is also user-driven (verbatim): "I moved the - Phase-1 sweep tally (run chat c76dd3d2): 4 findings fixed, 14 exempted with recorded exceptions, 5 judged not deviations; Verify green (cargo 171/0, UI 20/20 files). The sweep fanned out two read-only audit subagents (Rust rulebook detect-lists; UI Disposable/comment-policy) while the sweep author ran the mechanical checks - the parallel-audit pattern the plan review had suggested. - One coder subagent committed directly (a718b51) instead of following the dispatch-then-review pattern; the commit matched its step and was kept rather than reverted (paraphrase). -- Post-run footnote (run chat 59374b02): a stale `vibe-review.md` at the promptforge repo root (gitignored, predating the cabinet routing convention) surfaced three "open" findings against this plan's step 18 carve. They turned out already fixed - the reviewed commit 3d2b886 had been rewritten as ffb2506 containing all three fixes - so no commit was needed and the stale file was moved to cabinet/_trash/. +- Post-run footnote (run chat 59374b02): a stale `vibe-review.md` at the promptforge repo root (gitignored, predating the cabinet routing convention) surfaced three "open" findings against this plan's step 18 carve. They turned out already fixed - the reviewed commit 3d2b886 had been rewritten as ffb2506 containing all three fixes - so no commit was needed and the stale file was moved to cabinet/_trash/. diff --git a/vibe/2026-08-27-1-refresh-tree-on-drop.md b/vibe/2026-08/2026-08-27-1-refresh-tree-on-drop.md similarity index 99% rename from vibe/2026-08-27-1-refresh-tree-on-drop.md rename to vibe/2026-08/2026-08-27-1-refresh-tree-on-drop.md index 3069cec6..c8be1b3c 100644 --- a/vibe/2026-08-27-1-refresh-tree-on-drop.md +++ b/vibe/2026-08/2026-08-27-1-refresh-tree-on-drop.md @@ -81,4 +81,4 @@ The audit confirmed the plan was applied as commit `cbdbf57` "Make Explorer drop ## Note on paths -The audit found the UI sources live under `ui/src/ui/` (an extra `ui` segment), not the `ui/src/` paths the plan cites - relevant to anyone executing or amending the plan later. +The audit found the UI sources live under `ui/src/ui/` (an extra `ui` segment), not the `ui/src/` paths the plan cites - relevant to anyone executing or amending the plan later. diff --git a/vibe/2026-08-27-2-workshop-server-refactor.md b/vibe/2026-08/2026-08-27-2-workshop-server-refactor.md similarity index 99% rename from vibe/2026-08-27-2-workshop-server-refactor.md rename to vibe/2026-08/2026-08-27-2-workshop-server-refactor.md index 569b7a18..1cb891ab 100644 --- a/vibe/2026-08-27-2-workshop-server-refactor.md +++ b/vibe/2026-08/2026-08-27-2-workshop-server-refactor.md @@ -98,7 +98,7 @@ The plan executes the execution order of the server-delivery comparison study (` **Finding 4 (gateway merge)** was excluded as belonging to a plan already in flight (the plan file states this). -**Finding 8** folded into the Finding 7 resolution: with explicit per-file routes and no SPA fallback, the API-404 concern does not apply. +**Finding 8** folded into the Finding 7 resolution: with explicit per-file routes and no SPA fallback, the API-404 concern does not apply. ## Design thinking behind the ordering @@ -115,4 +115,4 @@ The plan executes the execution order of the server-delivery comparison study (` - **Step 3 review Critical:** the always-ready gateway branch in `select!` could starve status frames past `done`; it failed live during the review's gate run and was fixed with `biased;` ordering. - **Step 1 (from the run-chat review):** the harness reuses `serve::spawn` / `VoiceConfig` rather than exporting `app::fixtures` helpers - a sanctioned simplification, since Config/spawn were already public and no production change was needed. One Minor finding was delegated to step 3: the integration binary duplicates `#[cfg(test)]` fixture helpers (`transcribe::fixtures`, `spawn_gateway`), unavoidable without a production edit step 1 forbids; the stated fix is to gate the helpers behind a `test-fixtures` feature once a production-touching step allows it. - **Step 8 empirical correction to the design study:** held WebSockets do not block axum's graceful drain (upgrades detach); wedged in-flight HTTP requests are the real blocker. The watchdog bounds both. Recorded in code comments and tests. -- **Environmental, not refactor-caused:** 7 promptforge-mcp-server test failures trace to the user's live promptforge-gateway.exe (pid 26072) occupying 127.0.0.1:8081, where those tests expect connection-refused; reproduced identically at the pre-run baseline. 5 whisper-fixture voice tests fail when force-run on this machine, also pre-existing (stash-verified). The final verify was re-run excluding the blocked crate rather than killing the user's running process. +- **Environmental, not refactor-caused:** 7 promptforge-mcp-server test failures trace to the user's live promptforge-gateway.exe (pid 26072) occupying 127.0.0.1:8081, where those tests expect connection-refused; reproduced identically at the pre-run baseline. 5 whisper-fixture voice tests fail when force-run on this machine, also pre-existing (stash-verified). The final verify was re-run excluding the blocked crate rather than killing the user's running process. diff --git a/vibe/2026-08-27-3-server-driven-menu-state.md b/vibe/2026-08/2026-08-27-3-server-driven-menu-state.md similarity index 99% rename from vibe/2026-08-27-3-server-driven-menu-state.md rename to vibe/2026-08/2026-08-27-3-server-driven-menu-state.md index 2ea3e294..0a3c3340 100644 --- a/vibe/2026-08-27-3-server-driven-menu-state.md +++ b/vibe/2026-08/2026-08-27-3-server-driven-menu-state.md @@ -347,7 +347,7 @@ Further user directives that became plan requirements, verbatim: - "recording should be disabled when chat is unavailable" - mic gating in step 19. - "when the progress bar appears, the REC light should be hidden along with the LEDs. put the REC and the LEDs in its own group and just hide the group." - step 2. -- "mic should be disabled if no GPU" and "hide the controls entirely" - the voice-gating lineage. +- "mic should be disabled if no GPU" and "hide the controls entirely" - the voice-gating lineage. ## Discarded alternatives @@ -371,4 +371,4 @@ Further user directives that became plan requirements, verbatim: ## Why the plan reads the way it does -The final creator-chat directive, verbatim: "make the plan ready for a fresh context by pulling whatever it needs from this chat." This is why the plan carries the "Current state (what exists today)" and "Decisions already settled (do not reopen)" sections - they are this conversation's context, frozen into the document so a fresh executor never needs the chat. +The final creator-chat directive, verbatim: "make the plan ready for a fresh context by pulling whatever it needs from this chat." This is why the plan carries the "Current state (what exists today)" and "Decisions already settled (do not reopen)" sections - they are this conversation's context, frozen into the document so a fresh executor never needs the chat. diff --git a/vibe/2026-08-27-4-merged-gateway-workshop.md b/vibe/2026-08/2026-08-27-4-merged-gateway-workshop.md similarity index 99% rename from vibe/2026-08-27-4-merged-gateway-workshop.md rename to vibe/2026-08/2026-08-27-4-merged-gateway-workshop.md index 5a2dd239..e52f1e21 100644 --- a/vibe/2026-08-27-4-merged-gateway-workshop.md +++ b/vibe/2026-08/2026-08-27-4-merged-gateway-workshop.md @@ -234,7 +234,7 @@ When asked whether the workshop and gateway toml files should merge, the user pr > "I mean what about the menu settings, the window settings, saved stated, recently opened files, the path to the workshop database etc" -The resolution (assistant analysis the user accepted) was a four-way split by who writes the file and what losing it costs: config (human-edited toml), state (machine-written JSON beside the database, one file per writing component), view state (webview localStorage), data (the tape). The mechanical reason state must never merge into the toml: config is hand-edited and carries comments; if the program rewrites it to record "last opened: foo.md", serialization destroys comments, every click becomes a git diff, and the running app races the user's open editor. This is why `workshop-state.json` exists beside the tape file and why `[workshop]` in gateway.toml carries only boot config. +The resolution (assistant analysis the user accepted) was a four-way split by who writes the file and what losing it costs: config (human-edited toml), state (machine-written JSON beside the database, one file per writing component), view state (webview localStorage), data (the tape). The mechanical reason state must never merge into the toml: config is hand-edited and carries comments; if the program rewrites it to record "last opened: foo.md", serialization destroys comments, every click becomes a git diff, and the running app races the user's open editor. This is why `workshop-state.json` exists beside the tape file and why `[workshop]` in gateway.toml carries only boot config. ## Discarded or deferred alternatives @@ -250,7 +250,7 @@ The resolution (assistant analysis the user accepted) was a four-way split by wh - The commit-message format in the plan's pre-run step is the user's text, dictated verbatim on Aug 27 ("Legible first line, 60 chars max ... no mention of step numbers or total steps ... things that would not be immediately obvious from reading the code, or gotchas, or deviations from the plan"). Rationale (paraphrase): the ledger tracks steps, so the message should describe the change. - Step numbering: "steps are integers starting from 1, no letters (0a, 0b, etc)" - the assistant had drafted the hardening block as Step 0a-0e; the user made them Steps 1-6. -- The plan-as-deliverable doctrine: "the deliverable is no longer a design document. The plan itself is the deliverable. malleable, it shows each design element as a separate section. We add to it, update it, and from time to time we ask to have a small part of it extracted and placed at the beginning as an actionable step." +- The plan-as-deliverable doctrine: "the deliverable is no longer a design document. The plan itself is the deliverable. malleable, it shows each design element as a separate section. We add to it, update it, and from time to time we ask to have a small part of it extracted and placed at the beginning as an actionable step." ## Deviations during execution (run chat) @@ -262,4 +262,4 @@ The plan's run-state note says the run halted after the pre-run step; the run ch - **Post-run sweep with a review loop.** The user rejected single-pass fixing: "when you do the fix pass I want everything fixed not just one pass." The sweep ran a review-and-fix loop - each round re-reviews the diff including prior fixes, loop ends only when a round raises zero new findings, hard cap of three rounds. It closed at zero open findings after two rounds and 14 commits. The one Important finding (Step 9 shutdown-ordering test) landed as a `cfg(test)` observer seam after two reviewers rejected the brittle stall-based approach. - **Protocol overhead question.** Mid-run the user asked "okay but do we need the whole vibe protocol for every step?" - pressure on the per-step coder-plus-review ceremony that may motivate future rulebook tuning, but no change was made in this run. - **Left for manual verification** (GUI-bound, not automatable in the run): shell first-run flow on a clean profile dir, headless gateway with `open_browser = true`, and webview connection under the new Origin allowlist. -- **Config-dir clutter.** First real boot surfaced legacy residue: the user asked "why is there so much crap in C:\Users\Vinnie\.promptforge", "can I delete everything but the toml files", "what about workbench.toml" - evidence the legacy `workshop.toml` flow and stale state files still confuse the single-config story the merge was supposed to deliver. +- **Config-dir clutter.** First real boot surfaced legacy residue: the user asked "why is there so much crap in C:\Users\Vinnie\.promptforge", "can I delete everything but the toml files", "what about workbench.toml" - evidence the legacy `workshop.toml` flow and stale state files still confuse the single-config story the merge was supposed to deliver. diff --git a/vibe/2026-08-28-1-fix-open-vibe-findings.md b/vibe/2026-08/2026-08-28-1-fix-open-vibe-findings.md similarity index 99% rename from vibe/2026-08-28-1-fix-open-vibe-findings.md rename to vibe/2026-08/2026-08-28-1-fix-open-vibe-findings.md index de43f36b..d885c659 100644 --- a/vibe/2026-08-28-1-fix-open-vibe-findings.md +++ b/vibe/2026-08/2026-08-28-1-fix-open-vibe-findings.md @@ -1,83 +1,83 @@ ---- -name: Fix open vibe findings -overview: Clear all 15 open findings (1 Important, 14 Minor) from the merged-gateway run's vibe-review.md, each with its named fix, as a bounded sweep - three coder dispatches by crate, one review pass over the whole sweep, one final full verify. -todos: - - id: sweep-ws-server - content: "Coder A: ws-server findings (atomic x2, deadline, backoff x2, assets)" - status: completed - - id: sweep-gateway - content: "Coder B: gateway findings (runner x3, workshop.rs x2, shutdown-order test)" - status: completed - - id: sweep-rest - content: "Coder C: gateway-config loader, shell error-arm test, gateway README [server] table" - status: completed - - id: sweep-review - content: One review-and-fix pass over the whole sweep diff - status: completed - - id: sweep-verify - content: Final full-workspace verify; vibe-review.md holds zero open findings - status: completed -isProject: false ---- - -# Fix Open Vibe Findings - -## Scope - -The review file `cabinet/_scratch/vibe-gateway-workshop/vibe-review.md` holds 15 open findings from the merged gateway + workshop run: 1 Important, 14 Minor. Each finding names its fix. "Fix all" includes the three findings that offered a reject option (gateway README `[server]` table, `open_browser` seam, legacy `.tmp` sweep) - they get fixed, not rejected. - -## Loop shape (Bounded, not Full) - -The vibe-rulebook's Full per-step ceremony (coder + review-and-fix per commit) is skipped by deliberate sizing: these findings are the output of review, each with its fix already named, so a fresh review per one-line fix would review the review. This runs as a Bounded sweep: - -- Three coder subagents, one per crate area, each making its commits directly (one commit per finding cluster, rulebook commit-message format, vibe rule 7 keeps old-bug fixes isolated). -- One review-and-fix subagent over the whole sweep diff at the end, one fix round. -- One final full-workspace verify. -- Main still appends the ledger (`cabinet/_scratch/vibe-gateway-workshop/vibe-ledger.md`, new `## Findings sweep 2026-08-28` heading) and tracks the open-findings count to zero. - -Named rulebooks: `rust-rulebook.md` binds every fix (all are Rust). `vibe-rulebook.md` governs the loop as amended here. `html+css-rulebook.md` and `zed/docs/src/languages/typescript.md` bind nothing - no finding touches markup or TypeScript; carried as no-ops. - -Rules manifest (pass by path in every dispatch): `promptforge/AGENTS.md`, `crates/promptforge-ws-server/AGENTS.md`, `crates/promptforge-ws/AGENTS.md`. The gateway and gateway-config crates have no nested AGENTS.md. - -## Coder A: promptforge-ws-server (6 findings, up to 2 commits) - -- `atomic.rs:66` - reword the sweep doc: a missing directory is skipped silently, not logged. -- `atomic.rs:95` - the sweep also removes the legacy `workshop-state.json.tmp` name; add a test. -- `deadline.rs` - `start_paused = true` on `a_stalled_route_answers_408_at_its_deadline` (socketless oneshot makes paused time safe). -- `backoff.rs:111` - `% span.saturating_add(1)`. -- `backoff.rs:146` - make `xorshift` `pub(crate)` and delete the duplicate in `gateway.rs` tests. -- `assets.rs:45` - scope the parity comment: the guarantee covers request-supplied names; rust-embed 8.12.0's symlink bypass is outside it. - -Test: `cargo test -p promptforge-ws-server --lib`. Watch `module-ceilings.toml`: if a module grows past its ceiling, update to the actual value and state the raise reason in the commit message. - -## Coder B: promptforge-gateway (6 findings, up to 3 commits) - -- `runner.rs:263` - distinct `StartupError` kind for thread-spawn / pre-bind thread-exit failure (enum is `#[non_exhaustive]`) instead of misreporting as `bind`. -- `runner.rs:271,275` - downcast the discarded `thread.join()` panic payload into the returned error text. -- `runner.rs:488` - `check_workshop_matches_boot` names the first differing field (bind / open_browser / voice / tape), like the adjacent server check. -- `workshop.rs:346` - `#[allow(clippy::unnecessary_wraps)]` becomes `#[expect(...)]` (reviewer verified it compiles). -- `workshop.rs:98` - inject the browser opener as a seam; assert `open::that` is called with the workshop URL when `open_browser` is set. -- **Important** `runner.rs:215` - shutdown-ordering test. Do NOT use the stall-based approach the reviewer rejected (unverified hyper half-request drain semantics, 5+ wall-clock seconds). Add a sequence-recording seam: a `pub(crate)` ordering log or injected observer recording workshop-shutdown-complete before gateway-shutdown-signaled; the test spawns with a `[workshop]` section, shuts down, and asserts the recorded order. If the seam proves impossible without restructuring, return blocked with options rather than landing the brittle test. - -Tests: `cargo test -p promptforge-gateway --lib` and `--features workshop` variant. - -## Coder C: gateway-config + shell + docs (3 findings, up to 3 commits) - -- `promptforge-gateway-config/src/profile.rs:207` - one combined boot-section loader resolving the include chain once, returning both `[server]` and `[workshop]`; call it from `load_startup`. Existing boot tests stay green. -- `promptforge-ws/src/main.rs:74` - extract the `Option<&str> -> anyhow::Result` mapping into a testable pure function; assert the error names `[workshop]`. -- `promptforge-gateway/README.md:15` - add a short `[server]` field table (`bind`, `api_key`) so the shell README's "field reference" pointer is fully served. Docs-only. - -## Review and verify - -- Review-and-fix runs as a loop, not one round. Each round: a review-and-fix subagent applies `` to the full sweep diff (`git diff` from the pre-sweep HEAD) and fixes every finding it raises at every severity, then commits. The next round re-reviews the new diff including the previous round's fixes. The loop ends only when a round raises zero new findings. Hard cap: three rounds; if round three still raises findings, stop and report them rather than looping forever. -- The review-file contract holds across rounds: every fixed finding leaves `vibe-review.md`; the file ends at zero open findings. -- Final verify subagent after the loop closes: `cargo build -p promptforge-gateway`, `cargo build -p promptforge-gateway --features workshop`, `cargo build -p promptforge-ws`, `cargo test --workspace`, `cargo test -p promptforge-gateway --features workshop`. -- Done means: vibe-review.md holds zero open findings and the full suite is green. - -## Notes - -- Worktree must be clean at start; stop and report if dirty. -- If a coder finds a named fix wrong (code drifted since the finding), it fixes the underlying intent and says so in the commit message, per vibe rule 2. +--- +name: Fix open vibe findings +overview: Clear all 15 open findings (1 Important, 14 Minor) from the merged-gateway run's vibe-review.md, each with its named fix, as a bounded sweep - three coder dispatches by crate, one review pass over the whole sweep, one final full verify. +todos: + - id: sweep-ws-server + content: "Coder A: ws-server findings (atomic x2, deadline, backoff x2, assets)" + status: completed + - id: sweep-gateway + content: "Coder B: gateway findings (runner x3, workshop.rs x2, shutdown-order test)" + status: completed + - id: sweep-rest + content: "Coder C: gateway-config loader, shell error-arm test, gateway README [server] table" + status: completed + - id: sweep-review + content: One review-and-fix pass over the whole sweep diff + status: completed + - id: sweep-verify + content: Final full-workspace verify; vibe-review.md holds zero open findings + status: completed +isProject: false +--- + +# Fix Open Vibe Findings + +## Scope + +The review file `cabinet/_scratch/vibe-gateway-workshop/vibe-review.md` holds 15 open findings from the merged gateway + workshop run: 1 Important, 14 Minor. Each finding names its fix. "Fix all" includes the three findings that offered a reject option (gateway README `[server]` table, `open_browser` seam, legacy `.tmp` sweep) - they get fixed, not rejected. + +## Loop shape (Bounded, not Full) + +The vibe-rulebook's Full per-step ceremony (coder + review-and-fix per commit) is skipped by deliberate sizing: these findings are the output of review, each with its fix already named, so a fresh review per one-line fix would review the review. This runs as a Bounded sweep: + +- Three coder subagents, one per crate area, each making its commits directly (one commit per finding cluster, rulebook commit-message format, vibe rule 7 keeps old-bug fixes isolated). +- One review-and-fix subagent over the whole sweep diff at the end, one fix round. +- One final full-workspace verify. +- Main still appends the ledger (`cabinet/_scratch/vibe-gateway-workshop/vibe-ledger.md`, new `## Findings sweep 2026-08-28` heading) and tracks the open-findings count to zero. + +Named rulebooks: `rust-rulebook.md` binds every fix (all are Rust). `vibe-rulebook.md` governs the loop as amended here. `html+css-rulebook.md` and `zed/docs/src/languages/typescript.md` bind nothing - no finding touches markup or TypeScript; carried as no-ops. + +Rules manifest (pass by path in every dispatch): `promptforge/AGENTS.md`, `crates/promptforge-ws-server/AGENTS.md`, `crates/promptforge-ws/AGENTS.md`. The gateway and gateway-config crates have no nested AGENTS.md. + +## Coder A: promptforge-ws-server (6 findings, up to 2 commits) + +- `atomic.rs:66` - reword the sweep doc: a missing directory is skipped silently, not logged. +- `atomic.rs:95` - the sweep also removes the legacy `workshop-state.json.tmp` name; add a test. +- `deadline.rs` - `start_paused = true` on `a_stalled_route_answers_408_at_its_deadline` (socketless oneshot makes paused time safe). +- `backoff.rs:111` - `% span.saturating_add(1)`. +- `backoff.rs:146` - make `xorshift` `pub(crate)` and delete the duplicate in `gateway.rs` tests. +- `assets.rs:45` - scope the parity comment: the guarantee covers request-supplied names; rust-embed 8.12.0's symlink bypass is outside it. + +Test: `cargo test -p promptforge-ws-server --lib`. Watch `module-ceilings.toml`: if a module grows past its ceiling, update to the actual value and state the raise reason in the commit message. + +## Coder B: promptforge-gateway (6 findings, up to 3 commits) + +- `runner.rs:263` - distinct `StartupError` kind for thread-spawn / pre-bind thread-exit failure (enum is `#[non_exhaustive]`) instead of misreporting as `bind`. +- `runner.rs:271,275` - downcast the discarded `thread.join()` panic payload into the returned error text. +- `runner.rs:488` - `check_workshop_matches_boot` names the first differing field (bind / open_browser / voice / tape), like the adjacent server check. +- `workshop.rs:346` - `#[allow(clippy::unnecessary_wraps)]` becomes `#[expect(...)]` (reviewer verified it compiles). +- `workshop.rs:98` - inject the browser opener as a seam; assert `open::that` is called with the workshop URL when `open_browser` is set. +- **Important** `runner.rs:215` - shutdown-ordering test. Do NOT use the stall-based approach the reviewer rejected (unverified hyper half-request drain semantics, 5+ wall-clock seconds). Add a sequence-recording seam: a `pub(crate)` ordering log or injected observer recording workshop-shutdown-complete before gateway-shutdown-signaled; the test spawns with a `[workshop]` section, shuts down, and asserts the recorded order. If the seam proves impossible without restructuring, return blocked with options rather than landing the brittle test. + +Tests: `cargo test -p promptforge-gateway --lib` and `--features workshop` variant. + +## Coder C: gateway-config + shell + docs (3 findings, up to 3 commits) + +- `promptforge-gateway-config/src/profile.rs:207` - one combined boot-section loader resolving the include chain once, returning both `[server]` and `[workshop]`; call it from `load_startup`. Existing boot tests stay green. +- `promptforge-ws/src/main.rs:74` - extract the `Option<&str> -> anyhow::Result` mapping into a testable pure function; assert the error names `[workshop]`. +- `promptforge-gateway/README.md:15` - add a short `[server]` field table (`bind`, `api_key`) so the shell README's "field reference" pointer is fully served. Docs-only. + +## Review and verify + +- Review-and-fix runs as a loop, not one round. Each round: a review-and-fix subagent applies `` to the full sweep diff (`git diff` from the pre-sweep HEAD) and fixes every finding it raises at every severity, then commits. The next round re-reviews the new diff including the previous round's fixes. The loop ends only when a round raises zero new findings. Hard cap: three rounds; if round three still raises findings, stop and report them rather than looping forever. +- The review-file contract holds across rounds: every fixed finding leaves `vibe-review.md`; the file ends at zero open findings. +- Final verify subagent after the loop closes: `cargo build -p promptforge-gateway`, `cargo build -p promptforge-gateway --features workshop`, `cargo build -p promptforge-ws`, `cargo test --workspace`, `cargo test -p promptforge-gateway --features workshop`. +- Done means: vibe-review.md holds zero open findings and the full suite is green. + +## Notes + +- Worktree must be clean at start; stop and report if dirty. +- If a coder finds a named fix wrong (code drifted since the finding), it fixes the underlying intent and says so in the commit message, per vibe rule 2. --- @@ -120,4 +120,4 @@ The user overrode the single-pass review with: "when you do the fix pass I want ## Provenance notes - The stray `vibe-review.md` at the promptforge repo root that surfaced after the sweep ("there's stuff left?") was a stale, gitignored artifact of an older Step 18 smoke.mjs carve run, not this plan's review file. Its three findings turned out already fixed in a rewritten commit; the file was moved to trash. Not part of this plan's scope, but it explains the plan-file-vs-repo-root review-file distinction the plan relies on. -- One implementation trap worth keeping with the plan's memory: `Box` unsize-coerces to `dyn Any` if the box itself is passed to a downcast helper, silently losing the panic message; the fix derefs first (`&*payload`). This landed in the runner.rs join-panic fix. +- One implementation trap worth keeping with the plan's memory: `Box` unsize-coerces to `dyn Any` if the box itself is passed to a downcast helper, silently losing the panic message; the fix derefs first (`&*payload`). This landed in the runner.rs join-panic fix. diff --git a/vibe/2026-08-28-2-coroutine-protocol-executor.md b/vibe/2026-08/2026-08-28-2-coroutine-protocol-executor.md similarity index 99% rename from vibe/2026-08-28-2-coroutine-protocol-executor.md rename to vibe/2026-08/2026-08-28-2-coroutine-protocol-executor.md index 4da3bf86..49089568 100644 --- a/vibe/2026-08-28-2-coroutine-protocol-executor.md +++ b/vibe/2026-08/2026-08-28-2-coroutine-protocol-executor.md @@ -250,4 +250,4 @@ The run completed all 14 steps the same day it started. Deviations beyond what t - Step 14 held the run's one user decision. The pre-existing test fanout_store_writes_persist_across_arms rendezvoused arms by busy-polling store.glob in a Lua loop with no yielding host call - valid under thread-per-arm preemption, impossible under cooperative interleaving. The runner escalated with this framing: "this test pins an implementation detail (preemption), not any of the plan's listed preserved semantics." The user chose adapting the fixture to rendezvous through a yield (execute on a nop section inside the poll loop) over weakening the pin or re-planning for preemption; both directions were mutant-verified. This is the authoritative precedent for how "existing suites must pass unmodified" bends when a test pins preemption rather than a listed semantic. - The runtime split landed wider than the plan predicted: cli AND dev moved to current-thread runtimes; ws-server AND mcp-server keep multi-thread. The plan had named only promptforge-cli as the expected mover and ws-server as the keeper. - Two coder subagents aborted mid-step (Steps 10 and 13); in both cases a recovery coder assessed the partial work as coherent and completed it (Step 13's was briefly stashed, then popped and committed). Review caught two Criticals the plan could not anticipate: the root chain's client slot unseeded, so prose before any infer fell back to the env client (Step 7); and a pre-cancelled fanout returning Ok (Step 13), after which cancellation is checked at every chain-step boundary. -- The user twice interjected "you seem stuck" / "are you stuck?" during long steps; the run nonetheless finished with zero open findings and the full workspace suite green. +- The user twice interjected "you seem stuck" / "are you stuck?" during long steps; the run nonetheless finished with zero open findings and the full workspace suite green. diff --git a/vibe/2026-08-28-3-digest-marker-child-priority.md b/vibe/2026-08/2026-08-28-3-digest-marker-child-priority.md similarity index 99% rename from vibe/2026-08-28-3-digest-marker-child-priority.md rename to vibe/2026-08/2026-08-28-3-digest-marker-child-priority.md index a0438164..c0d6418a 100644 --- a/vibe/2026-08-28-3-digest-marker-child-priority.md +++ b/vibe/2026-08/2026-08-28-3-digest-marker-child-priority.md @@ -166,7 +166,7 @@ Design thinking behind each: - Digest marker: code reading showed `ensure_model`/`ensure_blob` re-hash every sha256-pinned GGUF on every cache hit - a full sequential read of 19-29 GB before llama-server even starts, then llama-server reads the same files again. The spike later measured the hash pass at ~8 of the 8.3 minutes of switch time, confirming it as the dominant cost of a switch. - Child priority: the "thud" was diagnosed as GPU/WDDM contention plus scheduling, not CPU or RAM starvation - all 96 cores idle while the desktop compositor waits on the display driver. Below-normal priority makes weight loading yield to interactive processes. -- Dialect: Gemma-4's template uses new `<|tool_call|>` / `<|turn|>` markers that neither scorer in `dialect.rs` recognized. +- Dialect: Gemma-4's template uses new `<|tool_call|>` / `<|turn|>` markers that neither scorer in `dialect.rs` recognized. ## Discarded alternatives and corrections @@ -182,4 +182,4 @@ Design thinking behind each: ## Execution note (post-plan, same chat) -The plan's `windows-sys`-based priority test was replaced at run time with a PowerShell self-report probe, because the workspace lints forbid `unsafe_code` at forbid level - same coverage, zero new dependencies. Recorded as a rule-2 solo deviation in the ledger. +The plan's `windows-sys`-based priority test was replaced at run time with a PowerShell self-report probe, because the workspace lints forbid `unsafe_code` at forbid level - same coverage, zero new dependencies. Recorded as a rule-2 solo deviation in the ledger. diff --git a/vibe/2026-08-28-4-cuda-llama-provisioning.md b/vibe/2026-08/2026-08-28-4-cuda-llama-provisioning.md similarity index 99% rename from vibe/2026-08-28-4-cuda-llama-provisioning.md rename to vibe/2026-08/2026-08-28-4-cuda-llama-provisioning.md index 0d05ded2..d6154a09 100644 --- a/vibe/2026-08-28-4-cuda-llama-provisioning.md +++ b/vibe/2026-08/2026-08-28-4-cuda-llama-provisioning.md @@ -248,4 +248,4 @@ The operator also set the documentation bar for commits: "enrich the plan to inc - **CI deviation:** the plan's "scheduled or self-hosted job" became concrete during the run - the operator installed and authenticated `gh` and stood up a self-hosted runner on the Blackwell host; post-run, the nightly cron in `cuda.yml` was uncommented and set to 2 AM Pacific. - **Rollout friction:** a stale gateway process survived its terminal and held ports 8081/7910, prompting a SO_REUSEADDR question; the answer is that Tokio deliberately omits it on Windows (where the flag permits port hijacking rather than TIME_WAIT rebind). The agent also overstepped by relaunching the gateway after being asked only to kill the stale process ("no I did not tell you to relaunch the gateway"). - **Post-rollout desktop-shell defects** surfaced immediately and were fixed outside the plan's commits: `open_browser = true` in the preserved config was honored alongside the desktop window (fixed in config; the flag is documented for headless use), and the web UI's custom window menu lacked rollover and focus-loss unpop behavior. -- **Outcome confirmation:** "Gemma4 runs beautifully now" - E2B on CUDA with MTP delivered the Unsloth-class behavior that motivated the plan. +- **Outcome confirmation:** "Gemma4 runs beautifully now" - E2B on CUDA with MTP delivered the Unsloth-class behavior that motivated the plan. diff --git a/vibe/2026-08-29-1-crate-extraction-execution.md b/vibe/2026-08/2026-08-29-1-crate-extraction-execution.md similarity index 99% rename from vibe/2026-08-29-1-crate-extraction-execution.md rename to vibe/2026-08/2026-08-29-1-crate-extraction-execution.md index ac461e37..fe18ccc9 100644 --- a/vibe/2026-08-29-1-crate-extraction-execution.md +++ b/vibe/2026-08/2026-08-29-1-crate-extraction-execution.md @@ -149,4 +149,4 @@ Recovered from the producing chat sessions by the plan ledger on 2026-09-04. Eve - Inject-or-duplicate of `normalize` - rejected for a wholesale move (Step 2). - A new crate for the workshop facade - rejected; the facade stays in ws-server (Step 3). - `promptforge-confinement` extraction - dropped; no shared jail exists. -- Fail-with-instructions as the release gate (the plan's original Step 8 design) - discarded after the operator's one-command override; auto-package-on-demand is the surviving design. +- Fail-with-instructions as the release gate (the plan's original Step 8 design) - discarded after the operator's one-command override; auto-package-on-demand is the surviving design. diff --git a/vibe/2026-08-29-2-rename-ws-crates-to-workshop.md b/vibe/2026-08/2026-08-29-2-rename-ws-crates-to-workshop.md similarity index 99% rename from vibe/2026-08-29-2-rename-ws-crates-to-workshop.md rename to vibe/2026-08/2026-08-29-2-rename-ws-crates-to-workshop.md index d2c9c376..feb2f954 100644 --- a/vibe/2026-08-29-2-rename-ws-crates-to-workshop.md +++ b/vibe/2026-08/2026-08-29-2-rename-ws-crates-to-workshop.md @@ -101,4 +101,4 @@ This plan was created inside the crate_extraction execution chat, minutes after ## Go/no-go gate -Approval was gated on churn. Before authorizing execution the user asked (verbatim): "how much churn is it". The measured answer: ~93 hand-edited lines across 31 files, 228 tracked files moved unchanged via `git mv` (180 of them the `ui/` tree), the diff ~95% pure renames, risk low. Only after that answer did the user say (verbatim): "run". +Approval was gated on churn. Before authorizing execution the user asked (verbatim): "how much churn is it". The measured answer: ~93 hand-edited lines across 31 files, 228 tracked files moved unchanged via `git mv` (180 of them the `ui/` tree), the diff ~95% pure renames, risk low. Only after that answer did the user say (verbatim): "run". diff --git a/vibe/2026-08-29-3-chat-ws-decomposition.md b/vibe/2026-08/2026-08-29-3-chat-ws-decomposition.md similarity index 99% rename from vibe/2026-08-29-3-chat-ws-decomposition.md rename to vibe/2026-08/2026-08-29-3-chat-ws-decomposition.md index e5c2e1a6..30e2a261 100644 --- a/vibe/2026-08-29-3-chat-ws-decomposition.md +++ b/vibe/2026-08/2026-08-29-3-chat-ws-decomposition.md @@ -223,7 +223,7 @@ The Aug 29 review killed most of it on two Critical findings (paraphrase): The decisive cut came when the user pointed at the long-term harness plan (`interactive_webhook_tool_9ab3c21f`) and asked whether it changed the proposal. It did: nearly every concern the generic core would polish - `ChatWork::Opening`, direct `GatewayClient` opening, `SsePayloadStream` polling, `forward_payload`, `delta_fields`, the gateway-specific `StreamTape` - is scheduled for deletion or radical change once chat executes through the PromptForge Lua harness. The assistant's formulation (paraphrase): investing in a polished generic abstraction around these would create infrastructure specifically for code scheduled for deletion. -Result: trimmed to two commits - house rules, then a pure structural split. Removed: generic stream core, `DoneState`, terminal taxonomy, progress sampling, listener gating, liveness enum, mock framework, error shrink, immediate TS codegen. Codegen moved to the harness plan specifically because that is when the protocol expands (Observer and `user_input` frames), so types move once. The user then had the deferrals grouped by destination: harness-plan items, independent follow-ups, recorded-but-not-scheduled survey ideas. +Result: trimmed to two commits - house rules, then a pure structural split. Removed: generic stream core, `DoneState`, terminal taxonomy, progress sampling, listener gating, liveness enum, mock framework, error shrink, immediate TS codegen. Codegen moved to the harness plan specifically because that is when the protocol expands (Observer and `user_input` frames), so types move once. The user then had the deferrals grouped by destination: harness-plan items, independent follow-ups, recorded-but-not-scheduled survey ideas. ## Where Step 1's house rules came from @@ -240,4 +240,4 @@ Applying the rust-rulebook at the user's direction set the final layout: `sessio - The final Verify was first dispatched synchronously and blocked the session. The user: "jesus christ you launched the task syncrhonously again for fucks sake" - then directed a permanent rulebook change: "modify the @tools-public/rulebooks/vibe-rulebook.md subagents must ALWAYS be asynchronous NEVER synchronous". The vibe-rulebook now carries that rule. - The user, believing this run finished, launched a second vibe run elsewhere: "I thought you were done and I launched another vibe in a separate chat". That run advanced HEAD and dirtied the tree, so the in-flight full-workspace Verify was stopped per the plan's stop-rather-than-absorb rule. Both commits (`eb8341a`, `add98be`) were already landed and are ancestors of the other run's HEAD. - The user then retired the one outstanding item: "We don't need the full workspace verify because that'll be handled by the other vibe pro- vibe coder. It'll do its own." The assistant concurred (paraphrase): the other run's final Verify certifies these commits in the only tree state that matters, CI exercises the split on push, and the nightly CUDA workflow covers the `--features cuda` check. -- On deferred items, the user's thesis: "none of these deferred items, none of these are top-level features. They're all implementation details that we would rediscover on our own when we go to implement the features that need them anyway. Is that correct?" Answer (paraphrase): correct for the structural items - techniques whose pain announces itself in the code when needed - with one carve-out: the harness-plan items are actual features and live in their own self-contained plan. The user also confirmed this plan file need not be kept for the deferred items; the source research at `tools-public/output/what-to-steal/` carries them in richer form. +- On deferred items, the user's thesis: "none of these deferred items, none of these are top-level features. They're all implementation details that we would rediscover on our own when we go to implement the features that need them anyway. Is that correct?" Answer (paraphrase): correct for the structural items - techniques whose pain announces itself in the code when needed - with one carve-out: the harness-plan items are actual features and live in their own self-contained plan. The user also confirmed this plan file need not be kept for the deferred items; the source research at `tools-public/output/what-to-steal/` carries them in richer form. diff --git a/vibe/2026-08-29-4-progress-architecture-rollout.md b/vibe/2026-08/2026-08-29-4-progress-architecture-rollout.md similarity index 99% rename from vibe/2026-08-29-4-progress-architecture-rollout.md rename to vibe/2026-08/2026-08-29-4-progress-architecture-rollout.md index c280461f..58338412 100644 --- a/vibe/2026-08-29-4-progress-architecture-rollout.md +++ b/vibe/2026-08/2026-08-29-4-progress-architecture-rollout.md @@ -254,7 +254,7 @@ The user proposed the pattern verbatim: "should we build a progress architecture The assistant's first response was that this is NSProgress / Eclipse SubMonitor, sound but over-engineering for one crate with four leaves. What changed the verdict was a 26-crate sweep that found four independently invented ad-hoc progress mechanisms already shipping (indicatif/tracing in gateway-local, per-request `DownloadProgress` SSE in cache.rs, the `&'static str` stage channel in `run_switch`, hand-built `push_progress` in provision.rs), none composing. (Paraphrase) "That's the tell" - duplication across four subsystems is the evidence the shared vocabulary is needed. -The two-consumer topology that justifies a standalone crate is the user's own: "because in theory there are two users. The gateway, and the workshop ui. I would want the gateway to offer an endpoint where you can get events to know how long it is taking to switch models or load. And then there's the workshop server, it receives the gateway events but it also incorporates that into its own larger progress context which includes the transcription, and then forwards that to the UI." +The two-consumer topology that justifies a standalone crate is the user's own: "because in theory there are two users. The gateway, and the workshop ui. I would want the gateway to offer an endpoint where you can get events to know how long it is taking to switch models or load. And then there's the workshop server, it receives the gateway events but it also incorporates that into its own larger progress context which includes the transcription, and then forwards that to the UI." ## The user's decisive design corrections @@ -290,4 +290,4 @@ Time-proportional weights rather than unit counts (Eclipse); never-backwards agg - Step 3 review caught a real bug beyond tests: a marker-hit early return in `verified.rs` left the verify leaf at 0.0 forever. - Post-run, user-directed: burn down 46 carried-forward Minor findings as a cleanup pass. The cleanup added a `fail()` terminal on `ProgressHandle` emitting `Finished { ok: false }` - an API addition the plan never specified - plus sticky terminal, snapshot replay, weight fallback, CRLF, and Indicator-floor decisions recorded in the cleanup commit. - The user declined the final full-matrix verification: "i dont want the final verification". -- Consequence outside the repo: the vibe rulebook itself was amended so an open finding of any severity blocks the next step (previously only Critical blocked), with fix rounds running until clear, capped at three. +- Consequence outside the repo: the vibe rulebook itself was amended so an open finding of any severity blocks the next step (previously only Critical blocked), with fix rounds running until clear, capped at three. diff --git a/vibe/2026-08-29-5-gateway-config-spa.md b/vibe/2026-08/2026-08-29-5-gateway-config-spa.md similarity index 99% rename from vibe/2026-08-29-5-gateway-config-spa.md rename to vibe/2026-08/2026-08-29-5-gateway-config-spa.md index 72b26f38..52791e91 100644 --- a/vibe/2026-08-29-5-gateway-config-spa.md +++ b/vibe/2026-08/2026-08-29-5-gateway-config-spa.md @@ -1124,4 +1124,4 @@ The user wanted the gateway's configuration to be fully operable from a UI so th - **MSRV break:** `sysinfo 0.39.6` (pulled in for the system-stats endpoint) requires rustc 1.95, breaking the workspace MSRV of 1.89; the user surfaced the build error and the dependency had to be pinned to a compatible version. - **Feature policy change (user-directed, after step 24):** "I want \"cargo build --release -p promptforge-workshop\" to build promptforge-workshop.exe with the config-ui and the gateway built in, always" and the gateway build "should always... include the config-ui feature." The user asked for the agent's opinion, agreed with the trade-off (default builds now require Node 22), and directed: "Add lean, concise guidance to that effect to the proper AGENTS.md in the repo." - **Workspace panel addition:** during the workspace-folder work the user added a UI refinement - "could it check every root for existence and then draw it in red with a line through it if absent." -- **Environment constraint (process, not design):** AwaitShell/background shell waits hang in this environment; run chats record that all commands must run as plain foreground shell calls. This cost roughly 2.5 hours of stalled execution in the first run chat. +- **Environment constraint (process, not design):** AwaitShell/background shell waits hang in this environment; run chats record that all commands must run as plain foreground shell calls. This cost roughly 2.5 hours of stalled execution in the first run chat. diff --git a/vibe/2026-08-30-1-chat-templates-injection-defense.md b/vibe/2026-08/2026-08-30-1-chat-templates-injection-defense.md similarity index 99% rename from vibe/2026-08-30-1-chat-templates-injection-defense.md rename to vibe/2026-08/2026-08-30-1-chat-templates-injection-defense.md index b7bcb92d..e566be28 100644 --- a/vibe/2026-08-30-1-chat-templates-injection-defense.md +++ b/vibe/2026-08/2026-08-30-1-chat-templates-injection-defense.md @@ -370,7 +370,7 @@ The STT move was motivated by an accounting defect the user spotted: "the worksh - **Immediate profile switch on Set Active.** The user caught this in the UI and corrected it: "'which profile is active' should be deferred just like every other change". - **Separate Cache/Downloads tab.** "or actually what if we get rid of the Cache tab and just incorporate this into the Local tab?" - **Cloud icon for remote models** ("the cloud icon sucks I had no idea what it was"); telephone icon briefly considered; "globe it is". -- **Redefining existing vocabulary** for the new profile semantics: "redefining definitions is confusing as hell". +- **Redefining existing vocabulary** for the new profile semantics: "redefining definitions is confusing as hell". ## Design details dictated by the user @@ -390,4 +390,4 @@ The STT move was motivated by an accounting defect the user spotted: "the worksh - **A stray plan was force-merged into this run.** When the agent spun the whisper stderr collision fix into its own plan file, the user rejected the split: "NO WHY THE FUCK DID YOU MAKE A NEW PLAN? JUST PUT IT IN THIS ONE!" The fix was absorbed into this run's scope. - **HF route consistency pass added.** After the README proxy route came up, the user probed the naming ("wouldn't /admin/hf/{repo} make more sense for EVERY hf path?"), caught the collision flaw themselves ("wait but then you can never have a repo named 'search' or 'model'"), and ordered "review all the hf routes make them consistent". - **Live bug reports folded into the run:** no UI for adding endpoints, endpoint association failing to save ("no endpoint specified"), Discover READMEs not rendering, secret-field visibility icon flicker, stray indicatif output in the workshop terminal, CPU logical-core count display wrong. These were fixed forward during the run rather than deferred. -- Process note: the user asked the runner to "keep the plan todo's up to date, check the items completed" during execution. +- Process note: the user asked the runner to "keep the plan todo's up to date, check the items completed" during execution. diff --git a/vibe/2026-08-31-1-fix-hf-proxy-consistency.md b/vibe/2026-08/2026-08-31-1-fix-hf-proxy-consistency.md similarity index 99% rename from vibe/2026-08-31-1-fix-hf-proxy-consistency.md rename to vibe/2026-08/2026-08-31-1-fix-hf-proxy-consistency.md index 9a9f2153..42d74d28 100644 --- a/vibe/2026-08-31-1-fix-hf-proxy-consistency.md +++ b/vibe/2026-08/2026-08-31-1-fix-hf-proxy-consistency.md @@ -151,4 +151,4 @@ The whisper stderr collision was initially planned as a separate plan file (`fix ## Verification note -The plan's manual TTY acceptance step exists because the whisper fix cannot be verified by automated tests; the agent noted a release build was required and the user already had one running. +The plan's manual TTY acceptance step exists because the whisper fix cannot be verified by automated tests; the agent noted a release build was required and the user already had one running. diff --git a/vibe/2026-08-31-2-rebuild-mdbook-from-includes.md b/vibe/2026-08/2026-08-31-2-rebuild-mdbook-from-includes.md similarity index 99% rename from vibe/2026-08-31-2-rebuild-mdbook-from-includes.md rename to vibe/2026-08/2026-08-31-2-rebuild-mdbook-from-includes.md index 147fd46e..de7604c8 100644 --- a/vibe/2026-08-31-2-rebuild-mdbook-from-includes.md +++ b/vibe/2026-08/2026-08-31-2-rebuild-mdbook-from-includes.md @@ -139,4 +139,4 @@ The run chat executed the plan as written; all deviations were forced by verific Runner's retrospective on the plan (paraphrase): data flow was sound (all 10 per-crate guides existed, include paths correct) and the step breakdown was efficient; the only gaps were the two verification assumptions above, and a dry-run `mdbook build` during planning would have surfaced both. -Note: after the plan completed (commit `7579e2c`, 25 files, +72/-2849), the run chat drifted into an unrelated crates.io publishing discussion; nothing in it bears on this plan. +Note: after the plan completed (commit `7579e2c`, 25 files, +72/-2849), the run chat drifted into an unrelated crates.io publishing discussion; nothing in it bears on this plan. diff --git a/vibe/2026-08-31-3-republish-crates.md b/vibe/2026-08/2026-08-31-3-republish-crates.md similarity index 100% rename from vibe/2026-08-31-3-republish-crates.md rename to vibe/2026-08/2026-08-31-3-republish-crates.md diff --git a/vibe/2026-08-31-4-core-support-api-refinements.md b/vibe/2026-08/2026-08-31-4-core-support-api-refinements.md similarity index 99% rename from vibe/2026-08-31-4-core-support-api-refinements.md rename to vibe/2026-08/2026-08-31-4-core-support-api-refinements.md index de4c8826..9676a0ef 100644 --- a/vibe/2026-08-31-4-core-support-api-refinements.md +++ b/vibe/2026-08/2026-08-31-4-core-support-api-refinements.md @@ -143,7 +143,7 @@ The plan initially specified a parent-link design (`parent: Option About; the user's ask was for visibili "do it all in one commit and adopt a light version of @tools-public/rulebooks/vibe-rulebook.md" - light meant a single Bounded step: one Coder subagent for all four changes, then Message, Review-and-Fix (zero findings, no amend), and a final Verify running the full suite (179/179 Rust, 57/57 workshop UI, 108/108 config UI tests). Result: commit `e6fa867a`, "Add evergreen release links and baked version display" (9 files, +91/-12). -One operational note from the chat: the evergreen upload step only takes effect on the next `promptforge-workshop-v*` tag; nothing retroactively fixes already-published releases. +One operational note from the chat: the evergreen upload step only takes effect on the next `promptforge-workshop-v*` tag; nothing retroactively fixes already-published releases. diff --git a/vibe/2026-09-02-5-crate-taxonomy-rename.md b/vibe/2026-09/2026-09-02-5-crate-taxonomy-rename.md similarity index 99% rename from vibe/2026-09-02-5-crate-taxonomy-rename.md rename to vibe/2026-09/2026-09-02-5-crate-taxonomy-rename.md index ce6fafc4..8551e001 100644 --- a/vibe/2026-09-02-5-crate-taxonomy-rename.md +++ b/vibe/2026-09/2026-09-02-5-crate-taxonomy-rename.md @@ -299,4 +299,4 @@ The plan must run only after the agentic-harness plan finishes. This was burned - Squashing was considered and rejected. User: "question: should we squash the whole changeset down to 1 commit?" then "so its best left alone?" - the nine green commits were kept as-is. - `promptforge-cli` was deleted during the run even though the plan kept it unchanged. User: "I say we delete promptforge-cli completely and do away with the problem entirely" and "the cli is useless, and we dont use crates.io anymore I gave up on that. and we should delete promptforge.md". Both were removed as extra work beyond the plan's nine steps. -- After the push, CI on the PR failed on Format and then Clippy; follow-up fixes were required beyond the plan's local verification gates. Local `cargo test --workspace --locked` did not cover what CI's format and clippy jobs caught. +- After the push, CI on the PR failed on Format and then Clippy; follow-up fixes were required beyond the plan's local verification gates. Local `cargo test --workspace --locked` did not cover what CI's format and clippy jobs caught. diff --git a/vibe/2026-09-02-6-product-user-guides.md b/vibe/2026-09/2026-09-02-6-product-user-guides.md similarity index 99% rename from vibe/2026-09-02-6-product-user-guides.md rename to vibe/2026-09/2026-09-02-6-product-user-guides.md index 95d63614..2d4ca41b 100644 --- a/vibe/2026-09-02-6-product-user-guides.md +++ b/vibe/2026-09/2026-09-02-6-product-user-guides.md @@ -228,4 +228,4 @@ A second motive is maintenance drag. On deleting the old design docs (step 0): " 1. **Gate 1 context exhaustion on the gateway set.** The gateway pipeline subagent exhausted its context twice with 14 jinja chat-template assets unextracted (226 files vs the language set's 93). The runner wrote the 14 heading-only extraction files itself and drove the remaining stages directly, breaking the letter of "one subagent per manifest file" while keeping its spirit (the files are pure data templates, empty for the operator audience; gate 2 passed downstream). The runner flagged it as a process deviation and offered strict-conformance remediation (delete the 14 scratch files, re-run the gateway lens); its read was not worth it, high confidence. 2. **Upstream push.** Step 9's "push triggers guide.yml" was read as requiring a push to upstream (cppalliance, where Pages deploys), which carried 11 unpushed crate-rename commits along with the plan's 14. The user was surprised ("you pushed to the upstream?"). The runner owned it, offered revert or reset options, and recommended leaving it (medium confidence). CI then failed on the `check` job for the "Assemble the book and add the introduction" commit and was investigated. -3. The user made a manual README edit during the run and asked the agent to commit it. +3. The user made a manual README edit during the run and asked the agent to commit it. diff --git a/vibe/2026-09-03-1-enhance-tts-endpoint-report.md b/vibe/2026-09/2026-09-03-1-enhance-tts-endpoint-report.md similarity index 100% rename from vibe/2026-09-03-1-enhance-tts-endpoint-report.md rename to vibe/2026-09/2026-09-03-1-enhance-tts-endpoint-report.md diff --git a/vibe/2026-09-03-2-enhance-image-endpoint-spec.md b/vibe/2026-09/2026-09-03-2-enhance-image-endpoint-spec.md similarity index 100% rename from vibe/2026-09-03-2-enhance-image-endpoint-spec.md rename to vibe/2026-09/2026-09-03-2-enhance-image-endpoint-spec.md diff --git a/vibe/2026-09-03-3-gateway-sidecar-decomposition.md b/vibe/2026-09/2026-09-03-3-gateway-sidecar-decomposition.md similarity index 99% rename from vibe/2026-09-03-3-gateway-sidecar-decomposition.md rename to vibe/2026-09/2026-09-03-3-gateway-sidecar-decomposition.md index d3837d35..7da585b6 100644 --- a/vibe/2026-09-03-3-gateway-sidecar-decomposition.md +++ b/vibe/2026-09/2026-09-03-3-gateway-sidecar-decomposition.md @@ -205,7 +205,7 @@ The emotional core of the thesis (paraphrase): closing the workshop window must 2. **One shared crate.** The user asked: "shouldn't we have just ONE crate which is the shared crate between gatway and workshop?" The answer (paraphrase): no - the repo's precedent is tiny-per-seam crates, and gateway-protocol's domain is the LLM upstream API, so stuffing process discovery there muddies it; the seam gets its own crate (shared-sidecar) serving exactly three consumers. But the user decreed the naming rule verbatim: "every shared crate should have "shared-" prefix" - hence the step-1 renames to shared-loopback / shared-protocol and the step-13 recorded convention. 3. **Binary rename reconsidered.** Mid-run the user asked "why are we renaming promptforge-gateway and promptforge-workshop?" The rationale (paraphrase): two generically named exes in one install dir identify nothing in Task Manager, firewall prompts, or logs; the shell launches the gateway by name, so a distinctive name cannot collide with some other "gateway.exe"; and the user had specced the names themselves in the installer message. The user accepted: "seems fine." 4. **First-run browser open.** Option B (auto-open on detected first run) was discarded because the shell's auto-launch of the gateway would produce both a workshop window and a browser tab, forcing a suppression-flag convention between components. Option A - an explicit `--open-settings` flag wired to the installer's finish page - was chosen by the user with "A". Underlying prior-art rule (paraphrase): a daemon never pops a browser on login-triggered starts (Syncthing's most-hated behavior), but "I just installed it" is an explicit user action. -5. **`--no-tray` naming and the service future.** The user asked "should it be called --headless, or --no-desktop instead?" The flag stayed `--no-tray` (paraphrase): it means "no desktop session exists" (CI, Docker, SSH), not "service mode". The user flagged the longer arc: "but I will want to make this an optional service eventually" - the sketched shape is the standard split-process pattern, a headless session-0 service plus a thin per-user tray client attaching over loopback. +5. **`--no-tray` naming and the service future.** The user asked "should it be called --headless, or --no-desktop instead?" The flag stayed `--no-tray` (paraphrase): it means "no desktop session exists" (CI, Docker, SSH), not "service mode". The user flagged the longer arc: "but I will want to make this an optional service eventually" - the sketched shape is the standard split-process pattern, a headless session-0 service plus a thin per-user tray client attaching over loopback. ## Execution-shape decisions @@ -227,4 +227,4 @@ Adjacent deferred decisions: ## Run chats (deviations check) -The three run chats are Review-and-Fix subagent dispatches (steps 1, 3, 9), not user conversations; they record no user deviations from the plan. What they add is operational intent the plan implies but does not spell out (all paraphrase): step 3's InstallSTT registry DWORD semantics (absent = STT on, zero = omit), first-run config generation must be create-new and never truncate an existing file or follow a planted symlink, and the generated api_key must stay CSPRNG-grade; step 9's ghost-icon-safe teardown order (drop TrayIcon, destroy the hidden window, then shut the gateway down; never process::exit), the `/auth?key=` handoff URL must never be logged or written to disk, muda MenuItems must be created and mutated only on the tray thread, and tray creation failure must degrade to headless serving without losing the connection file or the shutdown signal. +The three run chats are Review-and-Fix subagent dispatches (steps 1, 3, 9), not user conversations; they record no user deviations from the plan. What they add is operational intent the plan implies but does not spell out (all paraphrase): step 3's InstallSTT registry DWORD semantics (absent = STT on, zero = omit), first-run config generation must be create-new and never truncate an existing file or follow a planted symlink, and the generated api_key must stay CSPRNG-grade; step 9's ghost-icon-safe teardown order (drop TrayIcon, destroy the hidden window, then shut the gateway down; never process::exit), the `/auth?key=` handoff URL must never be logged or written to disk, muda MenuItems must be created and mutated only on the tray thread, and tray creation failure must degrade to headless serving without losing the connection file or the shutdown signal. diff --git a/vibe/2026-09-04-1-async-boot-and-progress.md b/vibe/2026-09/2026-09-04-1-async-boot-and-progress.md similarity index 99% rename from vibe/2026-09-04-1-async-boot-and-progress.md rename to vibe/2026-09/2026-09-04-1-async-boot-and-progress.md index e0b42062..fc4f7b4f 100644 --- a/vibe/2026-09-04-1-async-boot-and-progress.md +++ b/vibe/2026-09/2026-09-04-1-async-boot-and-progress.md @@ -265,4 +265,4 @@ The assistant explicitly conceded its framing lost: "My plan was still thinking - **gateway-local cancellation shape.** The dispatch suggested an async variant of `LocalRuntime::start`; the coder instead landed a sync token-aware variant driven by `spawn_blocking`, to keep the library runtime-agnostic per house rules. Recorded as a reversible decision. - **Boot test shape.** The boot integration tests were reworked off the slow-download-through-config approach because gateway-config validation rejects plaintext-http artifact sources (a trust-boundary rule the coder left intact). Mid-chunk cancel is pinned by a gateway-local fixture-listener unit test; quit-during-provisioning uses a switch parked in a bounded drain, fully rendezvous-driven. Recorded falsifier: if loopback http sources ever become legal, the slow-download shape can return. - **New dependency.** `tokio-util` (default-features off, `sync::CancellationToken` only) was added to manifests; it was already present in the workspace lock tree. -- **Checkpoint-commit suggestion declined.** Mid-step the user asked "would it be smarter to stage and commit, and then amend the commit later." The answer was no: uncommitted work is already safe on disk (the stalled coder died and lost nothing), a mid-write commit risks a torn snapshot in history, and the Message subagent's value is reading the complete staged diff as an independent check. +- **Checkpoint-commit suggestion declined.** Mid-step the user asked "would it be smarter to stage and commit, and then amend the commit later." The answer was no: uncommitted work is already safe on disk (the stalled coder died and lost nothing), a mid-write commit risks a torn snapshot in history, and the Message subagent's value is reading the complete staged diff as an independent check. diff --git a/vibe/2026-09-04-2-apply-as-queue-command.md b/vibe/2026-09/2026-09-04-2-apply-as-queue-command.md similarity index 99% rename from vibe/2026-09-04-2-apply-as-queue-command.md rename to vibe/2026-09/2026-09-04-2-apply-as-queue-command.md index c9312a64..4d394b40 100644 --- a/vibe/2026-09-04-2-apply-as-queue-command.md +++ b/vibe/2026-09/2026-09-04-2-apply-as-queue-command.md @@ -259,7 +259,7 @@ The assistant deliberately split the work into two plan files (paraphrase of its ## The queue philosophy behind Part A -Part A is not an isolated fix; it extends an architecture the user dictated earlier that morning (verbatim): "Everything is commands in the command queue. So we always know what's active and what's pending. We can always cancel anything. And if we want to, we can expose that to an endpoint so that the model can understand what the gateway is doing. So the model can work on the gateway itself." Supporting constraints from the same discussion (verbatim): "we have to make sure we debounce commands I dont want multuple profile switch commands queued at once" and "design this so we can add user-cancelation of downloads later". Apply was the one route still bypassing that queue; the hung overlay was the visible symptom. +Part A is not an isolated fix; it extends an architecture the user dictated earlier that morning (verbatim): "Everything is commands in the command queue. So we always know what's active and what's pending. We can always cancel anything. And if we want to, we can expose that to an endpoint so that the model can understand what the gateway is doing. So the model can work on the gateway itself." Supporting constraints from the same discussion (verbatim): "we have to make sure we debounce commands I dont want multuple profile switch commands queued at once" and "design this so we can add user-cancelation of downloads later". Apply was the one route still bypassing that queue; the hung overlay was the visible symptom. ## The light execution schedule @@ -277,7 +277,7 @@ Part A: - Debounce direction: Apply supersedes and cancels an in-flight LoadProfile because the newly applied config may invalidate what is loading; a LoadProfile arriving while Apply is pending waits, because Apply is the broader operation. - Apply lock: narrowed to the snapshot/prepare step and the commit step only; holding it across the whole switch was the deadlock. The genuine race identified was commit-time shadow promotion versus a concurrent save. - Commit semantics: considered re-verifying the state shadow still matches the snapshot and skipping promotion if diverged; settled on always writing the snapshot's exact content to the real files at commit, deleting the shadow only if it is unchanged. The two invariants this preserves: the real file always mirrors live routing, and the shadow always represents what is still pending. -- Deferred promotion (the plan's central correctness move): eager shadow promotion in `prepare_apply` was abandoned after tracing a recovery gap - once the config is promoted, a failed or cancelled switch leaves no shadows, `needs_reload` goes false, retry is a no-op, and live routing can point at stopped runtimes. Deferring both config and state promotion to the commit keeps a failed or cancelled apply retryable, which the plan's overview states as a goal. +- Deferred promotion (the plan's central correctness move): eager shadow promotion in `prepare_apply` was abandoned after tracing a recovery gap - once the config is promoted, a failed or cancelled switch leaves no shadows, `needs_reload` goes false, retry is a no-op, and live routing can point at stopped runtimes. Deferring both config and state promotion to the commit keeps a failed or cancelled apply retryable, which the plan's overview states as a goal. Part B: @@ -288,4 +288,4 @@ Part B: ## Out-of-scope item -The plan's "Discovered, out of scope" note (the switch lock held across downloads blocking `begin_inference`) was itself a chat discovery during the Part A diagnosis, flagged so it would not slip; the user did not discuss it. +The plan's "Discovered, out of scope" note (the switch lock held across downloads blocking `begin_inference`) was itself a chat discovery during the Part A diagnosis, flagged so it would not slip; the user did not discuss it. diff --git a/vibe/2026-09-04-3-unlock-inference-during-switches.md b/vibe/2026-09/2026-09-04-3-unlock-inference-during-switches.md similarity index 99% rename from vibe/2026-09-04-3-unlock-inference-during-switches.md rename to vibe/2026-09/2026-09-04-3-unlock-inference-during-switches.md index d2112c1e..49ed2a5b 100644 --- a/vibe/2026-09-04-3-unlock-inference-during-switches.md +++ b/vibe/2026-09/2026-09-04-3-unlock-inference-during-switches.md @@ -195,7 +195,7 @@ The one judgment call the planner refused to make alone: once old runtimes are s ## Plan-review corrections before "run" -The owner's "review the plan" pass caught a real defect: the headline regression test was unsatisfiable as first drafted, because at cold boot routing is empty until cut-over and cut-over came after the download - remote requests would have 404'd through the whole boot download, the exact case that matters. The fix is the two-order rule now in the plan's diagram: cut over immediately when there is nothing old to stop, otherwise download first. Same review corrected the interim state (STT may survive the stop), dropped the phantom `stopping-models` leaf on boot, and parallelized steps 1 and 2 after confirming their file sets are disjoint. +The owner's "review the plan" pass caught a real defect: the headline regression test was unsatisfiable as first drafted, because at cold boot routing is empty until cut-over and cut-over came after the download - remote requests would have 404'd through the whole boot download, the exact case that matters. The fix is the two-order rule now in the plan's diagram: cut over immediately when there is nothing old to stop, otherwise download first. Same review corrected the interim state (STT may survive the stop), dropped the phantom `stopping-models` leaf on boot, and parallelized steps 1 and 2 after confirming their file sets are disjoint. ## Mid-run scope growth: steps 4 and 5 were born during execution @@ -223,4 +223,4 @@ Step 6 (hygiene): the `onCancel`-rejection test the plan said to confirm turned Rule-7 fix (`f80bfeeb`): final Verify round 1 failed at `cargo test --workspace` - a boot test spawned an exe that crashed with 0xC0000139 because workspace feature unification turns on `muda/common-controls-v6` and `TaskDialogIndirect` is unbound without a v6 manifest. Package-scoped builds never saw it because they rebuild the exe without the unified feature. Fix embeds the manifest in `build.rs`. Recorded lesson: the final Verify's workspace build is the only check that catches feature-unification load failures. -Run complete 2026-09-04 19:09: seven commits `b020d2a2..f80bfeeb` on master, unpushed, 0 open findings (1 Important and 8 Minor closed, 3 Minor rejected). +Run complete 2026-09-04 19:09: seven commits `b020d2a2..f80bfeeb` on master, unpushed, 0 open findings (1 Important and 8 Minor closed, 3 Minor rejected). diff --git a/vibe/2026-09-05-1-gateway-logging-cli.md b/vibe/2026-09/2026-09-05-1-gateway-logging-cli.md similarity index 100% rename from vibe/2026-09-05-1-gateway-logging-cli.md rename to vibe/2026-09/2026-09-05-1-gateway-logging-cli.md diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09/2026-09-05-2-generic-realtime-stt.md similarity index 100% rename from vibe/2026-09-05-2-generic-realtime-stt.md rename to vibe/2026-09/2026-09-05-2-generic-realtime-stt.md diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09/2026-09-07-1-promptforge-debt.md similarity index 100% rename from vibe/2026-09-07-1-promptforge-debt.md rename to vibe/2026-09/2026-09-07-1-promptforge-debt.md diff --git a/vibe/2026-09-07-2-gateway-tts-phase-1.md b/vibe/2026-09/2026-09-07-2-gateway-tts-phase-1.md similarity index 100% rename from vibe/2026-09-07-2-gateway-tts-phase-1.md rename to vibe/2026-09/2026-09-07-2-gateway-tts-phase-1.md diff --git a/vibe/2026-09-08-1-remove-unsupported-ratchets.md b/vibe/2026-09/2026-09-08-1-remove-unsupported-ratchets.md similarity index 100% rename from vibe/2026-09-08-1-remove-unsupported-ratchets.md rename to vibe/2026-09/2026-09-08-1-remove-unsupported-ratchets.md diff --git a/vibe/2026-09-09-2-async-stt-boot-reset.md b/vibe/2026-09/2026-09-09-2-async-stt-boot-reset.md similarity index 100% rename from vibe/2026-09-09-2-async-stt-boot-reset.md rename to vibe/2026-09/2026-09-09-2-async-stt-boot-reset.md From 9627cb93d0e5b19ac837e59cda8663cf8630b63e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 14 Sep 2026 09:33:21 -0700 Subject: [PATCH 30/30] [WIP] Plan: Provider Model Sheets --- vibe/2026-09-14-2-provider-model-sheets.md | 680 +++++++++++++++++++++ vibe/ACTIVE | 1 + 2 files changed, 681 insertions(+) create mode 100644 vibe/2026-09-14-2-provider-model-sheets.md create mode 100644 vibe/ACTIVE diff --git a/vibe/2026-09-14-2-provider-model-sheets.md b/vibe/2026-09-14-2-provider-model-sheets.md new file mode 100644 index 00000000..be65f0b5 --- /dev/null +++ b/vibe/2026-09-14-2-provider-model-sheets.md @@ -0,0 +1,680 @@ +--- +name: Provider Model Sheets +overview: "Phase 1 infrastructure for provider model sheets: two new shared crates - shared-gateway-api (the normalized sheet schema, plus the hoisted Capabilities/ModelInfo/ModelKind/ThinkingMode at their canonical home) and shared-cloud-providers (tiered per-provider descriptors for chat, image, STT, and TTS providers, fetch/normalize logic, and the sheet-building binary) - plus a scheduled GitHub workflow in the promptforge repo that aggregates vendor model-list endpoints into a models.json release artifact with last-known-good propagation. Phase 2 (deferred): the Gateway's sheet-consumption path and config-UI integration." +todos: + - id: settle-open-questions + content: Settle remaining open questions (raw payload embedding, v1 scope confirmation, cadence) + status: pending + - id: shared-gateway-api + content: Create shared-gateway-api with the sheet schema types (Sheet, ProviderSlice, SliceStatus, ModelEntry, Thinking, Pricing, Deprecation, Tier); hoist Capabilities, ModelInfo, ModelKind (extended with transcription/image/video), ThinkingMode from gateway-config/gateway-protocol with re-exports at the old paths + status: pending + - id: shared-cloud-providers + content: Create shared-cloud-providers (lib + bin) with Provider descriptor (name, display_name, tier, key_env, base_url), provider registry, per-provider files, fetch/normalize behind an injected reqwest::Client, build_sheet/fetch_sheet + status: pending + - id: aggregation-workflow + content: Build the GitHub workflow (manual + cron) that compiles the binary, runs it with secrets as env vars, and publishes models.json as a release artifact + status: pending +isProject: false +--- + +# Provider Model Sheets + + + +## Product Requirements + +The Gateway today knows a remote model only through hand-written `[[model]]` entries in `gateway.toml` (`crates/gateway-config/src/config.rs`, `ModelConfig`). Closed-weight providers change their lineups constantly, and each provider's model-list endpoint speaks its own dialect of auth, pagination, and response shape. This plan adds a single aggregation point: a scheduled GitHub workflow builds a machine-readable models sheet from every vendor's model-list endpoint and publishes it as a release artifact, and a provider-descriptor crate linked into the Gateway lets the Gateway understand each provider's offerings and normalize them into model choices for the config UI. + +- Problem and users: model metadata for closed-weight providers is hand-maintained in configuration and goes stale; each provider's model-list API differs in auth, pagination, and response shape. Users are Gateway operators picking models in the config UI, and downstream hosts - Workshop, the PromptForge Agent Harness (not yet written), and the PromptForge CLI (not yet written) - which consume models through the Gateway's normal catalog. +- Goals: + - A types-only crate `shared-gateway-api` holding the normalized sheet schema structs, consumed by the provider crate, the Gateway, and Workshop server (UI elements such as the model dropdown). + - A crate `shared-cloud-providers` with one Rust file per provider (`anthropic.rs`, `openai.rs`, `gemini.rs`, `moonshot.rs`, and so on), each defining a public `Provider` descriptor, plus the fetch/normalize logic and the sheet-building binary. + - A GitHub workflow, triggerable manually and on a schedule, that calls every provider's model-list endpoint with keys held in GitHub secrets and builds the models sheet. + - The sheet published as a release artifact in the promptforge repo, so any Gateway downloads it for free with no provider key of its own. + - (Phase 2) The Gateway consumes the sheet and normalizes provider models into choices for the config UI; hosts consume the normalized models through the Gateway as usual. +- Non-goals: the Agent Harness and CLI themselves; local (GGUF) model metadata; changes to the Gateway's routing or `[[model]]` resolution semantics. Phase 1 is infrastructure only: no UI changes and no Gateway sheet-consumption - the user's words: "I don't want anything changed in the UI yet. First I want to get the infrastructure in place and reliable to build the table." The type hoist IS in phase 1 scope: the user's words: "I still want to relocate the gateway types to shared-gateway-api." +- Success criteria: the workflow produces a current, schema-valid sheet on demand and on schedule; a failed provider fetch propagates last-known-good data; adding a new provider is one new Rust file plus one GitHub secret. (Phase 2 criterion, not phase 1: a Gateway with no provider keys boots against the release artifact and presents normalized provider models in the config UI.) +- Constraints: provider API keys live only in GitHub secrets and never ship in the artifact or the crate; the artifact is safe to fetch unauthenticated; the crate follows workspace conventions (edition 2024, workspace lints, no file over 500 lines). +- Settled questions (2026-09-14): + - Raw payload embedding: no. Model entries carry normalized fields only; the artifact stays small, schema-stable, and free of provider-specific shapes leaking into consumers. + - First-iteration scope: the Prime tier only - Anthropic, OpenAI, Google Gemini, xAI, DeepSeek, Alibaba Qwen, Moonshot AI, Meta, ElevenLabs, Deepgram. Subprime providers, Niche static lists, and Aggregators follow once the Prime pipeline is proven. The schema and `build_sheet` keep `static` slice support, but no Niche provider files ship in v1. + - Schedule cadence: weekly, plus manual dispatch. + +### Provider Landscape + +Verified against official documentation on 2026-09-14. Response richness matters because it decides how much of the normalized schema each provider file can fill from the list endpoint alone. Starter-list dedupes: Grok is xAI, Gemini is Google DeepMind, Kimi is Moonshot AI. + +American closed-weight: + +- Anthropic - `https://api.anthropic.com`, `GET /v1/models`, `x-api-key` header + required `anthropic-version` header. Rich: capabilities object, token limits, display name. Cursor pagination. Docs: docs.anthropic.com/en/api/models-list +- OpenAI - `https://api.openai.com/v1`, `GET /v1/models`, Bearer. IDs only (`id`, `created`, `owned_by`); no pagination, no limits or capabilities. Docs: developers.openai.com/api/reference +- Google Gemini - `https://generativelanguage.googleapis.com`, `GET /v1beta/models`, `?key=` query param or `x-goog-api-key` header. Verified 2026-09-14: 403 without a key. Richest: `inputTokenLimit`, `outputTokenLimit`, `supportedGenerationMethods`, thinking flag. `pageToken` pagination. Docs: ai.google.dev/api/models +- xAI - `https://api.x.ai`, `GET /v1/models`, Bearer. OpenAI-shaped but extended: `aliases`, `context_length`, per-token pricing. No pagination. Docs: docs.x.ai +- Amazon Nova (Bedrock) - `https://bedrock.{region}.amazonaws.com`, `GET /foundation-models`, AWS SigV4 (no bearer). Modalities and lifecycle, no context window. Valid `{region}` values (35, control-plane endpoints table, docs.aws.amazon.com/general/latest/gr/bedrock.html): us-east-1, us-east-2, us-west-1, us-west-2, af-south-1, ap-east-2, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2, ap-southeast-1 through ap-southeast-7, ca-central-1, ca-west-1, eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2, eu-west-3, il-central-1, me-central-1, me-south-1, mx-central-1, sa-east-1, us-gov-east-1, us-gov-west-1. The Provider descriptor needs a region field (or a pinned default region) for this one. Docs: docs.aws.amazon.com/bedrock +- Microsoft (Azure AI Foundry) - per-resource URL, no global endpoint; `GET {endpoint}/openai/v1/models`, `api-key` header or Bearer. Basic info only. Docs: learn.microsoft.com/rest/api/aifoundry +- Meta - first-party Meta Model API exists: `https://api.meta.ai/v1`, `GET /v1/models`, Bearer, OpenAI-compatible. Response schema not fully enumerated. Docs: ai.developer.meta.com/docs + +Chinese providers: + +- Moonshot AI (Kimi) - both weights. `https://api.moonshot.ai/v1` global, `https://api.moonshot.cn/v1` China (keys not interchangeable). `GET /v1/models`, Bearer. Enriched: `context_length`, image/video input and reasoning flags. No pagination. +- DeepSeek - open-weight. `https://api.deepseek.com`, `GET /models`, Bearer. IDs only. +- Alibaba Qwen (DashScope / Model Studio) - both weights. `https://dashscope.aliyuncs.com/compatible-mode/v1`, `GET .../models`, Bearer. Plain OpenAI shape on the compatible endpoint; the native `/api/v1/models` adds pagination, pricing, and context length. +- Zhipu AI (GLM) - both weights. `https://open.bigmodel.cn/api/paas/v4`, Bearer. Flag: no officially documented model-list endpoint; catalog lives on a docs page. +- MiniMax - both weights. `https://api.minimax.io/v1` global, `https://api.minimaxi.com/v1` China. `GET /v1/models`, Bearer. Plain OpenAI shape. +- ByteDance Doubao (Volcano Ark) - closed. `https://ark.cn-beijing.volces.com/api/v3`, Bearer. Flag: no API-key-callable model list; the catalog endpoint needs control-plane AK/SK signing. +- Baidu (ERNIE / Qianfan) - both weights. `https://qianfan.baidubce.com/v2`, `GET /v2/models`, Bearer. Richest of the set: `context_length`, `max_tokens`, modality, pricing. +- StepFun - both weights. `https://api.stepfun.com/v1` (CN) / `.ai` (intl). `GET /v1/models`, Bearer. Plain OpenAI shape. +- iFlytek Spark - closed. Flag: no documented model-list endpoint; IDs enumerated only on doc pages. +- 01.AI Yi - pivoted away from foundation models in 2025; platform longevity uncertain. Exclude from v1. + +European and other: + +- Mistral AI - France, both weights. `https://api.mistral.ai`, `GET /v1/models`, Bearer (401 without, verified 2026-09-14). Rich: capabilities (chat/fim/function_calling/vision), `max_context_length`, `aliases`, `deprecation`. No pagination. +- Cohere - Canada, both weights. `https://api.cohere.com`, `GET /v1/models`, Bearer (401 without, verified 2026-09-14). `context_length`, `endpoints`, `features`; token pagination. +- AI21 Labs - Israel, both weights. Flag: no documented model-list endpoint; IDs documented statically. +- Perplexity (Sonar) - US, closed. `https://api.perplexity.ai`, `GET /v1/models`. Verified 2026-09-14: listing requires auth (401 without a key); response shape not officially documented. Canonical chat is `POST /v1/sonar`; `/chat/completions` is an alias. +- NVIDIA (build.nvidia.com) - US, both weights. `https://integrate.api.nvidia.com/v1`, `GET /v1/models`. Verified live 2026-09-14: no auth required for listing, full model list returned. IDs only, namespaced (`meta/llama-3.1-8b-instruct`); `created` is a constant placeholder on every entry. Rerank/retrieval use separate base `ai.api.nvidia.com/v1`. + +Media providers (verified 2026-09-14): + +Image generation: + +- Midjourney - excluded: no official public API exists (enterprise API is application-stage only; every "Midjourney API" on the market is a ToS-violating wrapper). +- Kling AI (Kuaishou; the "Kang.ai" the user mentioned) - `https://api.klingai.com`, JWT (HS256) from an AccessKey/SecretKey pair, static model IDs (Kolors family, `kling-v1` through `kling-v3`). +- Real model-list endpoints: Google (`GET /v1beta/models`), OpenAI (`GET /v1/models`), Leonardo (`GET /platformModels`). Partial: Stability (`GET /v1/engines/list`, legacy v1 only); Ideogram and Adobe list custom models only. +- Static IDs only: Black Forest Labs (`x-key` header), Recraft, Kling, ByteDance Seedream (Ark), Alibaba Wan (DashScope), xAI image (unverified), Runway (Bearer + version header), Luma. +- Deprecation landmines: Google Imagen 4 shut down 2026-08-17 (succeeded by Gemini 3.1 Flash Image); OpenAI gpt-image-1.x sunsets 2026-12-01 (use gpt-image-2); DALL-E already gone. + +Speech to text: + +- Real model-list endpoints: Deepgram (`GET /v1/models`, rich: languages, version, batch/streaming flags; `Authorization: Token` prefix), Groq (`GET /openai/v1/models`), Soniox (`GET /v1/models` with per-model languages), Azure Speech (`GET /speechtotext/v3.2/models/base`). +- Static IDs: AssemblyAI, ElevenLabs Scribe, Gladia, Rev.ai, AWS Transcribe, Cartesia. Google STT exposes capability discovery via its Locations API instead; Speechmatics has `GET /v1/discovery/features`. + +Text to speech: + +- PlayHT - excluded: acquired by Meta, API offline since 2025-07, platform sunset 2025-12-31. +- Real model-list endpoints: ElevenLabs (`GET /v1/models`, rich: languages, capabilities, rates; `xi-api-key` header), Deepgram (`GET /v1/models`, TTS array with languages and tags). +- Static IDs: Cartesia (also requires a `Cartesia-Version` date header), Murf, OpenAI, Google, Azure, Amazon Polly, MiniMax, Hume, Resemble. Voice-list endpoints are near-universal even where model lists are absent. + +Aggregators (not providers, but relevant): + +- OpenRouter - `GET https://openrouter.ai/api/v1/models`, no auth for listing. Verified live 2026-09-14: 718 KB payload; each model has `id`, `canonical_slug`, `name`, `created`, `description`, `context_length`, `architecture` (modality, input/output modalities, tokenizer), `pricing` (prompt/completion USD per token, cache read), `top_provider` (`max_completion_tokens`, `is_moderated`), `supported_parameters`, plus server-side filtering and pagination. A viable complement or fallback source. +- SiliconFlow - the major Chinese aggregator; one OpenAI-compatible endpoint across most Chinese providers. + +Design consequences: auth variance is confirmed across a dozen shapes (`x-api-key`, Bearer, `?key=` query param, `api-key` header, SigV4, `Token` prefix, `xi-api-key`, `x-gladia-key`, `Ocp-Apim-Subscription-Key`, JWT-from-AK/SK, OAuth2, Basic), which validates the private-variance encapsulation. Media providers are mostly Niche by the functional definition: outside Deepgram, ElevenLabs, Groq, Soniox, Azure Speech, Google, and OpenAI, media catalogs are static ID lists, and TTS voice discovery is near-universal even where model discovery is absent. (The richness split and field-availability constraints live with the schema in Technical Design.) + +### Provider Signup + +The signup checklist, one table per tier. Endpoint facts are verified from the 2026-09-14 survey; console URLs are from knowledge, not re-verified. Tier assignment is functional: Prime and Subprime have a working key-callable model-list endpoint, Niche do not (their sheet slices are static lists compiled into the binary, so no key is needed for aggregation), Aggregators list many providers' models through one endpoint. + +Prime: + +| Name | URL | Notes | +| --- | --- | --- | +| Anthropic | https://console.anthropic.com/ | Keys under Settings; usage credits need a card, listing is free | +| OpenAI | https://platform.openai.com/api-keys | Billing setup required before keys work | +| Google Gemini | https://aistudio.google.com/apikey | Free tier, no card needed | +| xAI | https://console.x.ai/ | Paid credits | +| DeepSeek | https://platform.deepseek.com/ | Prepaid balance, inexpensive | +| Alibaba Qwen | https://modelstudio.console.alibabacloud.com/ | Use the international console; the China console may require real-name verification | +| Moonshot AI | https://platform.moonshot.ai/ | Global variant; .ai and .cn keys are not interchangeable | +| Meta | https://ai.developer.meta.com/ | Newer first-party program | + +Subprime: + +| Name | URL | Notes | +| --- | --- | --- | +| Mistral AI | https://console.mistral.ai/ | Free experiment tier | +| Cohere | https://dashboard.cohere.com/ | Trial keys free, rate-limited | +| Baidu (Qianfan) | https://qianfan.cloud.baidu.com/ | Chinese console; real-name verification likely | +| MiniMax | https://platform.minimax.io/ | Global variant (.io, not .com) | +| StepFun | https://platform.stepfun.ai/ | International variant | +| Amazon Nova (Bedrock) | https://console.aws.amazon.com/bedrock/ | Heaviest setup: AWS account, IAM credentials, SigV4, pick a region | +| Microsoft Foundry | https://ai.azure.com/ | Azure subscription plus a deployed resource; no global endpoint | +| NVIDIA | https://build.nvidia.com/ | No key needed for model listing (verified); key only for inference | + +Niche (no key needed for the sheet; static lists ship in the binary): + +| Name | URL | Notes | +| --- | --- | --- | +| Zhipu AI (GLM) | https://open.bigmodel.cn/ | No officially documented model-list endpoint | +| ByteDance Doubao | https://www.volcengine.com/ | Model catalog needs control-plane AK/SK signing | +| iFlytek Spark | https://www.xfyun.cn/ | No documented model-list endpoint | +| AI21 Labs | https://studio.ai21.com/ | No documented model-list endpoint | +| Perplexity | https://www.perplexity.ai/settings/api | List endpoint exists but requires auth (401 verified); shape undocumented | + +Aggregator: + +| Name | URL | Notes | +| --- | --- | --- | +| OpenRouter | https://openrouter.ai/keys | No key needed for model listing; key only for inference | +| SiliconFlow | https://cloud.siliconflow.cn/ | Chinese aggregator; one endpoint across most Chinese providers | + +Suggested signup order: + +1. Free and instant: Google Gemini, NVIDIA, Mistral, Cohere (no card, keys in minutes). OpenRouter needs nothing for listing. +2. Card-required majors: Anthropic, OpenAI, xAI, DeepSeek, Meta. +3. Heavy setup: Amazon Bedrock (AWS account, IAM, SigV4, region choice), Microsoft Foundry (Azure subscription plus a deployed resource). +4. Chinese consoles last: Alibaba Model Studio international, Moonshot global, MiniMax global, StepFun international, Baidu Qianfan (real-name verification overhead). +5. Niche providers: skip entirely - their sheet slices are static lists compiled into the binary. + +### Media Signup + +Image, speech-to-text, and text-to-speech providers. Same caveats as above: endpoint facts verified 2026-09-14, console URLs from knowledge. Providers already covered by a chat-provider signup row (OpenAI, Google, Azure, MiniMax, xAI, Alibaba) are omitted - their media models ride the same key. + +Image generation: + +| Name | URL | Notes | +| --- | --- | --- | +| Leonardo | https://leonardo.ai/ | Subprime; real list endpoint (`GET /platformModels`) | +| Kling AI | https://klingai.com/ | Niche; static list; JWT from an AccessKey/SecretKey pair | +| Black Forest Labs | https://bfl.ai/ | Niche; static list; `x-key` header | +| Recraft | https://www.recraft.ai/ | Niche; static list | +| Ideogram | https://ideogram.ai/ | Niche; lists custom models only | +| Adobe Firefly | https://developer.adobe.com/firefly-services/ | Niche; OAuth client credentials; custom models only | +| Runway | https://runwayml.com/ | Niche; Bearer plus a version header | +| Luma | https://lumalabs.ai/ | Niche; static list | +| Stability AI | https://platform.stability.ai/ | Niche; list endpoint is legacy v1 only | + +Speech to text: + +| Name | URL | Notes | +| --- | --- | --- | +| ElevenLabs (Scribe) | https://elevenlabs.io/ | Prime; one key covers TTS and STT | +| Deepgram | https://console.deepgram.com/ | Prime; rich list endpoint; `Authorization: Token` prefix | +| Groq | https://console.groq.com/ | Subprime; OpenAI-compatible list endpoint; doubles as a fast chat provider | +| Soniox | https://console.soniox.com/ | Subprime; list endpoint with per-model languages | +| AssemblyAI | https://www.assemblyai.com/dashboard | Niche; static list | +| Speechmatics | https://www.speechmatics.com/ | Niche; capability-discovery endpoint, no model list | +| Gladia | https://www.gladia.io/ | Niche; static list; `x-gladia-key` header | +| Rev.ai | https://www.rev.ai/ | Niche; static list | +| AWS Transcribe | https://console.aws.amazon.com/ | Niche; covered by the Bedrock/AWS signup | +| Cartesia | https://play.cartesia.ai/ | Niche; static list; one key covers TTS too | + +Text to speech: + +| Name | URL | Notes | +| --- | --- | --- | +| ElevenLabs | (see STT table) | Prime; the TTS category leader; rich list endpoint | +| Deepgram | (see STT table) | Prime; TTS array in the same list endpoint | +| Cartesia | (see STT table) | Niche; also requires a `Cartesia-Version` date header | +| Murf | https://murf.ai/ | Niche; static list; `api-key` header | +| Hume | https://www.hume.ai/ | Niche; static list; `X-Hume-Api-Key` header | +| Resemble | https://app.resemble.ai/ | Niche; model auto-selected from `voice_uuid` | +| Amazon Polly | https://console.aws.amazon.com/ | Niche; covered by the Bedrock/AWS signup | +| Inworld | https://inworld.ai/ | Niche; list endpoint referenced in docs but unverified | + +## Functional Specification + +Two pipelines share one vocabulary. The build pipeline (workflow) turns provider list-endpoint responses into the sheet; the consumption pipeline (Gateway) turns the sheet into catalog choices. `shared-gateway-api` is the shared vocabulary between them. + +- Actors and workflows: + - The workflow (phase 1): on manual dispatch or schedule, compile the crate's binary and run it with provider keys injected from secrets as environment variables; the binary downloads the previous release's sheet (if any), calls each provider's model-list endpoint, normalizes the responses, propagates previous slices for failed fetches, and writes the merged sheet; the workflow publishes it as the new release artifact. + - The Gateway (phase 2, deferred): fetch the sheet from the release artifact, cache it, and re-serve the normalized catalog on its own route, so hosts consume it from the Gateway rather than fetching from GitHub themselves; the config UI's model choices derive from it. + - Hosts (Workshop now; Agent Harness and CLI later): consume models through the Gateway's existing catalog surface (`crates/gateway/src/model_info.rs`, `CatalogModelsResponse`). (Phase 2, deferred: Workshop server additionally links `shared-gateway-api` directly for UI elements such as the model dropdown.) +- Inputs and outputs: provider list-endpoint JSON in; one `models.json` sheet out, wrapped in an envelope with `schema_version`, `generated_at` (RFC 3339), and a `providers` map keyed by provider name. (Phase 2: Gateway config-UI model choices derived from the sheet.) +- States and validation: each provider entry has a `status` of `ok` (fetched fresh this run), `stale` (fetch failed; the previous sheet's slice was propagated verbatim with its original `fetched_at`), `unavailable` (fetch failed and no previous sheet existed; `models` is empty), or `static` (Niche provider with no list endpoint; a hand-maintained model list compiled into the binary, no fetch attempted). +- Errors and recovery: a failed provider fetch never fails the workflow run and never drops data: the previous sheet's slice for that provider is propagated with `status: "stale"`, preserving its original `fetched_at` so consumers can see the age of the data. A first-ever run with a failed fetch records `unavailable` with an empty model list. +- Security and privacy behavior: keys exist only as GitHub secrets injected into the workflow environment; the sheet and the crate contain no secrets. +- Acceptance criteria (phase 1): `cargo run -p shared-cloud-providers` locally with keys in the environment produces a schema-valid `models.json`; the workflow does the same on manual dispatch and publishes it as a release artifact; a provider with a missing key or failed fetch appears as `stale` (with its previous slice) or `unavailable`, never as a build failure; a Niche provider emits its static list with `status: "static"`; the hoisted types compile at their old paths via re-export with no downstream call-site changes. + + + + +## Technical Design + +The central design fact is the separation between the public descriptor and the private variance. Each provider file exposes a uniform `Provider` struct; everything provider-specific stays inside the file. + +```mermaid +flowchart TD + subgraph gh [GitHub] + anth[Anthropic] + oai[OpenAI] + gem[Gemini] + anth & oai & gem --> wf[Workflow] + wf -->|normalize| sheet[models.json] + end + + sheet -->|fetch| gw[Gateway] +``` + +Dependency map: + +```mermaid +flowchart TD + gw[gateway] --> sga[gateway-api] + gw --> scp[cloud-providers] + ws[workshop-server] --> sga + gha[GHA workflow] -->|bin target| scp + scp --> sga + scp --> reqwest[reqwest] + sga --> serde[serde] + sga --> time[time] +``` + +`shared-gateway-api` is pure vocabulary (serde, time; no workspace dependencies, per the `shared-*` substrate rule). `shared-cloud-providers` adds reqwest behind the injected-client seam. The Gateway links both; Workshop server links only the schema crate; the workflow consumes only the `bin` target. + +- Architecture: + - Two new workspace crates. `shared-gateway-api`: types-only - the sheet envelope, per-provider entry, and per-model entry structs; no product-crate dependencies, mirroring the `shared-promptforge-api` precedent. `shared-cloud-providers`: the `Provider` descriptors, the provider registry, and the per-provider fetch and normalization logic, doing double duty as a `lib` and a thin `bin` (read keys from the environment, fetch the previous sheet, run every provider, write `models.json`) that the GitHub workflow compiles and runs - and that anyone can compile and run locally for testing and sheet building. The user's rationale: "it can also be compiled and run locally for testing and building." + - Consumers: `shared-cloud-providers` depends on `shared-gateway-api`; the Gateway links both (schema for sheet parsing, provider registry for provider metadata); Workshop server links `shared-gateway-api` for UI elements such as the model dropdown. The workspace dependency rules force the schema into `shared-*`: `workshop-*` crates may never depend on `gateway-*` crates. + - One Rust file per provider in `shared-cloud-providers`: `anthropic.rs`, `openai.rs`, `gemini.rs`, `moonshot.rs`, etc. + - Each file defines a public `Provider` descriptor: provider name, tier, the environment-variable name of its API key (matching the GitHub secret name), and the default base URL. Tier is a curated product opinion, not a vendor fact: `prime`, `subprime`, `niche`, `aggregator`. Bedrock additionally needs a region (35 valid values, listed in the Provider Landscape) or a pinned default. + - The variances - auth header shape (`x-api-key` vs `Authorization: Bearer` vs query param), pagination, response field names, capability mapping - are private to each provider file. + - HTTP is needed on both ends (provider endpoints in the binary, sheet download in the Gateway), so `shared-cloud-providers` takes an injected `reqwest::Client` rather than owning one. The Gateway has no shared client to hand it - each upstream privately builds three role-specific clients via `gateway-protocol/src/http_util.rs` (`bounded_client`, `streaming_client`, `audio_streaming_client`; see `gateway-protocol/src/upstream.rs` lines 197-243) - so the Gateway constructs one purpose-built bounded client for sheet downloads from the same factory. + - Because the fetch and normalization logic lives in the `lib`, it is unit-testable offline against recorded fixture JSON; live endpoints are exercised only by manual or scheduled runs of the binary. +- Modules and interfaces: `shared-gateway-api` is the canonical home of the hoisted model-metadata types - `Capabilities`, `ModelInfo`, `ModelKind`, `ThinkingMode`, moved out of `gateway-config` and `gateway-protocol`, which re-import them - plus the sheet schema types (envelope, per-provider entry, per-model entry). `shared-cloud-providers` exports the `Provider` descriptor type and the registry of known providers, so the workflow binary, the Gateway, and Workshop server share one definition. The hoisted inventory is the "what a model can do" half of the existing config structs, a split the `Capabilities` doc comment (`crates/gateway-config/src/config.rs` lines 569-575) already states explicitly: `kind`, `description`, `context`, `thinking`, and the `Capabilities` fields (`max_output`, `default_temperature`, `images`, `parallel_tool_calls`, `effort_levels`, `default_effort`, `adaptive_thinking`, `voices`); the "how the gateway reaches it" half (`upstream`, `endpoints`, `source`, `sha256`, `dominion`, and the local-model tuning fields) stays put. +- Sheet schema: a single `models.json`. Field names mirror the Gateway's existing `Capabilities` vocabulary (`crates/gateway-config/src/config.rs`: `max_output`, `images`, `effort_levels`, `default_effort`) wherever concepts overlap, so normalizing a sheet entry into a `ModelConfig` is mechanical. The schema below is the union of what the surveyed list endpoints actually report (see Provider Landscape and the 2026-09-14 response-shape extractions): + +```json +{ + "schema_version": 1, + "generated_at": "2026-09-14T13:00:00Z", + "providers": { + "anthropic": { + "display_name": "Anthropic", + "tier": "prime", + "status": "ok", + "fetched_at": "2026-09-14T13:00:00Z", + "models": [ + { + "id": "claude-opus-5", + "display_name": "Claude Opus 5", + "released_at": "2026-07-24", + "context_window": 1000000, + "max_output": 128000, + "images": true, + "pdf_input": true, + "video_input": false, + "audio_input": false, + "batch": true, + "citations": true, + "code_execution": true, + "structured_outputs": true, + "tool_calling": true, + "thinking": { "supported": true, "enabled": false, "adaptive": true }, + "effort_levels": ["low", "medium", "high", "xhigh", "max"], + "default_effort": "high", + "pricing": { "currency": "USD", "prompt_per_mtok": 5.0, "completion_per_mtok": 25.0 }, + "deprecation": null + } + ] + } + } +} +``` + + - Envelope: `schema_version` (integer, bumped on breaking change), `generated_at` (RFC 3339, always this run's time), `providers` map keyed by provider name. + - Provider entry: `status` (`ok` / `stale` / `unavailable` / `static`), `fetched_at` (RFC 3339; preserved from the original fetch when `stale`; omitted when `static`), `models` array. + - Model entry: `id` (the upstream slug), `display_name`, `released_at` (optional - not every provider reports it), `context_window` (optional - the IDs-only providers omit it), `max_output` (optional), modality booleans (`images`, `pdf_input`, `video_input`, `audio_input`), capability booleans (`batch`, `citations`, `code_execution`, `structured_outputs`, `tool_calling`), a `thinking` object (`supported` = any reasoning, `enabled` = manual budget mode, `adaptive` = model-chosen), `effort_levels`, `default_effort` (optional), `pricing` (optional), `deprecation` (optional or null). +- Normalization principle: normalize the knob, never the settings. `effort_levels` is a list of the provider's own level names as strings - the observed union across all surveyed providers is exactly `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` - and no cross-provider ordinal scale is ever invented. Kimi's `["low","high","max"]` and Anthropic's five levels both fit without mapping. The same principle applies to `thinking`: Anthropic's thinking types collapse into the `enabled`/`adaptive` booleans; every other provider's reasoning flag collapses into `supported`. +- Field availability constraint (verified 2026-09-14 against official docs): only Anthropic and the OpenRouter aggregate expose effort levels in the list response; Kimi's `low`/`high`/`max` live in its chat-request docs, not its list endpoint. Effort data for other providers is statically curated in the provider file or omitted. Pricing appears natively in xAI, DashScope-native, Baidu, Perplexity's router, and OpenRouter responses - normalized to per-million-token units with an explicit `currency` field, because Baidu reports CNY per 1k tokens and xAI reports USD cents per 100M. Deprecation appears only in Bedrock (`modelLifecycle`), Mistral (`deprecation` + replacement), Cohere (`is_deprecated`), and OpenRouter (`expiration_date`). +- Workflow propagation algorithm: download the previous release's `models.json` before building; per provider, a successful fetch writes a fresh slice (`ok`, `fetched_at` = now) and a failed fetch copies the previous slice verbatim with `status` rewritten to `stale`; a provider with no previous slice and a failed fetch records `unavailable` with an empty `models` array. +- Public Rust declarations. `shared-gateway-api` (types-only; the hoisted `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` join these at the same canonical home): + +```rust +use std::collections::BTreeMap; +use serde::{Deserialize, Serialize}; +use time::{Date, OffsetDateTime}; + +/// The sheet envelope: one atomic snapshot of every provider's models. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sheet { + pub schema_version: u32, + /// RFC 3339; always this run's time. + pub generated_at: OffsetDateTime, + /// Keyed by provider name, e.g. "anthropic". + pub providers: BTreeMap, +} + +/// One provider's slice of the sheet. Self-describing: the descriptor's +/// public fields are copied in at build time so consumers can render a +/// provider dropdown from the sheet alone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderSlice { + pub display_name: String, + pub tier: Tier, + pub status: SliceStatus, + /// Last fresh fetch; absent for `static` slices. + pub fetched_at: Option, + pub models: Vec, +} + +/// Curated product opinion, not a vendor fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Tier { + Prime, + Subprime, + Niche, + Aggregator, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SliceStatus { + Ok, + Stale, + Unavailable, + Static, +} + +/// One normalized model entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelEntry { + pub id: String, + pub display_name: String, + /// The workload: chat, embedding, classifier, speech (TTS), + /// transcription (STT), image, video. + pub kind: ModelKind, + pub released_at: Option, + pub context_window: Option, + pub max_output: Option, + // Modalities. + pub images: bool, + pub pdf_input: bool, + pub video_input: bool, + pub audio_input: bool, + // Capabilities. + pub batch: bool, + pub citations: bool, + pub code_execution: bool, + pub structured_outputs: bool, + pub tool_calling: bool, + pub thinking: Thinking, + /// The provider's own level names, e.g. ["low", "high", "max"]; + /// never mapped to a cross-provider scale. + pub effort_levels: Vec, + pub default_effort: Option, + pub pricing: Option, + pub deprecation: Option, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct Thinking { + /// Any reasoning capability at all. + pub supported: bool, + /// Manual budget mode (Anthropic "enabled"). + pub enabled: bool, + /// Model-chosen thinking depth. + pub adaptive: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pricing { + /// ISO 4217, e.g. "USD", "CNY". + pub currency: String, + pub prompt_per_mtok: f64, + pub completion_per_mtok: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Deprecation { + pub status: String, + pub date: Option, + pub replacement: Option, +} +``` + + `shared-cloud-providers` (descriptors, registry, fetch seam; the `bin` target is a thin `main` over these): + +```rust +/// The public descriptor for one provider. Everything else about the +/// provider - auth header shape, pagination, response mapping - is +/// private to its file. +pub struct Provider { + /// Registry key, e.g. "anthropic". + pub name: &'static str, + /// UI-facing name, e.g. "Anthropic". + pub display_name: &'static str, + pub tier: shared_gateway_api::Tier, + /// Environment variable the API key arrives under; matches the + /// GitHub secret name. + pub key_env: &'static str, + /// Default base URL for the model-list endpoint. + pub base_url: &'static str, +} + +/// Every known provider. +pub fn providers() -> &'static [Provider]; + +/// Fetch and normalize one provider's model list; the per-provider +/// variance lives behind this seam. The client is injected by the +/// caller (the Gateway's bounded client, or the binary's own). +pub async fn fetch_models( + client: &reqwest::Client, + provider: &Provider, + key: &str, +) -> Result, FetchError>; + +/// Build the complete sheet: fetch every provider, propagate +/// last-known-good slices from `previous` for failed fetches, emit +/// static slices for Niche providers, assemble the envelope. This is +/// the function the binary's `main` calls. +pub async fn build_sheet( + client: &reqwest::Client, + previous: Option, + keys: &dyn Fn(&Provider) -> Option, +) -> shared_gateway_api::Sheet; + +/// Download and parse the current sheet from the release artifact. +/// This is the function the Gateway calls. +pub async fn fetch_sheet( + client: &reqwest::Client, + release_url: &str, +) -> Result; +``` +- File and public API changes (phase 1): two new crates (`shared-gateway-api`, `shared-cloud-providers`); the hoist moves `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` from `gateway-config`/`gateway-protocol` into `shared-gateway-api`, with re-exports at the old paths so downstream call sites compile unchanged; one new workflow file under `.github/workflows/`. Nothing else in the existing crates is modified. (Phase 2, deferred: the Gateway's sheet-consumption path and the config-UI integration; `ModelConfig`/`Routing`/catalog-wire questions get settled then.) +- Data, persistence, failure, security, and privacy constraints: the sheet is a versioned JSON artifact on a GitHub release in the promptforge repo; `BTreeMap` key ordering makes the emitted file byte-deterministic for clean diffs between runs. (Phase 2, unsettled: the Gateway's fetch-and-cache behavior - startup fetch, TTL, offline fallback to a vendored copy.) + + + + +## Testing Plan + +The fetch and normalization logic lives in the `shared-cloud-providers` lib precisely so it is testable offline; live endpoints are exercised only by manual or scheduled binary runs. + +- Unit: each provider file's normalization is tested against recorded fixture JSON (the three live payloads captured 2026-09-14 - Anthropic, OpenRouter, NVIDIA - seed the fixture set; documented example responses from official docs cover the rest); sheet schema round-trip tests (serialize, parse, compare); propagation tests (failed fetch with a previous sheet yields `stale` with preserved `fetched_at`; failed fetch without one yields `unavailable`; Niche providers yield `static`); the hoist is proven by the workspace compiling with re-exports and no call-site changes. +- Integration and end-to-end: a local run of the binary against recorded fixtures produces a schema-valid `models.json`; a manual workflow dispatch in GitHub produces and publishes the real artifact. +- Regression, security, and performance: no keys in the artifact or the crate (CI check: the sheet contains no secret material); existing gateway and workshop suites stay green through the hoist. +- Exit criteria: workspace nextest, doctests, clippy `-D warnings`, and `cargo fmt --all --check` green; a published release artifact exists and parses as a valid `Sheet`. + + + + +## Decision Record + +- Decisions: + - One Rust file per provider: the user's words - "I want each provider in its own rust file. anthropic.rs gemini.rs openai.rs moonshot.rs and so on." + - The provider file defines a public descriptor struct named `Provider` with fields for the API-key environment-variable name and the default URL: the user's words - "the provider file defines the name of the API key, the default URL, basically there is a descriptor lets call it struct Provider." + - The descriptor is public while the variances are private: the user's words - "the descriptor is public, while the variances are private - the variances are the little bullshit things that differ between providers." + - The crate links into the Gateway and exists so the Gateway understands provider offerings and normalizes them into config-UI choices: the user's words - "the rust crate is to link into the gateway so the gateway can understand what each provider offers, and normalize its models into a set of chocies for the config ui." + - Downstream consumers are Workshop, the PromptForge Agent Harness (not yet written), and the PromptForge CLI (not yet written), consuming models through the Gateway normally. + - Aggregation runs in a GitHub workflow with provider keys in GitHub secrets, manually triggerable and scheduled, publishing the sheet as a release artifact: the user's original framing. The workflow and the release artifact live in the promptforge repo itself: the user's words - "downloads the metadata file from github as a release artifact in the promptforge repo." + - The sheet is a single `models.json` holding everything, wrapped in an envelope with `schema_version`, `generated_at` (RFC 3339), and a `providers` map: the payload is tiny (~8 KB for Anthropic's 11 models; well under 1 MB at full provider coverage), every consumer wants the whole catalog, and one file gives one atomic snapshot with no version skew between provider slices. + - A failed provider fetch propagates the previous sheet's data for that provider rather than degrading to a marker alone: the user's words - "what happens on a failed fetch? it should propagate the previous file's data." The propagated slice is marked `stale` and keeps its original `fetched_at`. + - The sheet schema's field names mirror the Gateway's existing `Capabilities` vocabulary where concepts overlap, so sheet-to-`ModelConfig` normalization is mechanical rather than a second mapping layer. + - The crate does double duty - `lib` linked into the Gateway, `bin` run by the workflow and locally: the user's words - "it should be both what is compiled in to the gateway, and also what is compiled and runs on GHA. Rationale: it can also be compiled and run locally for testing and building." One normalization codebase, no curl/jq divergence, and offline-testable fetch logic. + - The provider crate is named `shared-cloud-providers`: the user's words - "lets call this new crate shared-cloud-providers." + - The normalized model definition structs live in their own types-only crate, consumed by `shared-cloud-providers`, the Gateway, and Workshop server: the user's words - "there should be a shared crate with the normalized model definition structs, shared-cloud-providers should consume that and gateway should consume that. and probably workshop-server would consume it because it corresponds to UI elements such as the model dropdown." The workspace dependency rules force this: `workshop-*` may never depend on `gateway-*`, so any type the Workshop UI needs must live in `shared-*`. + - That crate is named `shared-gateway-api`: the user's words - "it would be shared-gateway-api." The name follows the existing `shared-promptforge-api` precedent. + - HTTP client injection: `shared-cloud-providers` takes an injected `reqwest::Client`; the Gateway constructs a purpose-built bounded client for sheet downloads via the existing `gateway-protocol/src/http_util.rs` factory, because no shared client exists (each upstream builds three role-specific clients privately; the only injection seam today is `#[cfg(test)]`). + - Providers are tiered `prime` / `subprime` / `niche` / `aggregator`, a curated field on the `Provider` descriptor: the user's words - "the providers should be tiered: Prime, Underdog, Niche, Aggregator", with the second tier renamed per "rename underdog to Subprime." The definition is functional: Prime, Subprime, and Aggregator all have a working key-callable model-list endpoint that normalizes cleanly; Niche providers are listed but have no usable list endpoint. + - Niche providers ship hand-maintained static model lists compiled into the binary, emitted with `status: "static"` and no fetch attempted: a curated static list is the extreme case of private variance, and the tier label tells the UI how fresh to expect the data to be. + - Tier assignments (user-approved): Prime - Anthropic, OpenAI, Google Gemini, xAI, DeepSeek, Alibaba Qwen, Moonshot AI, Meta, ElevenLabs (TTS+STT), Deepgram (STT+TTS). Subprime - Mistral, Cohere, Baidu, MiniMax, StepFun, Amazon Nova (Bedrock), Microsoft Foundry, NVIDIA, Groq, Soniox, Azure Speech, Leonardo. Niche - Zhipu, ByteDance Doubao, iFlytek, AI21, Perplexity, plus the static-list media providers (Kling, Black Forest Labs, Recraft, Ideogram, Adobe Firefly, Runway, Luma, Stability, AssemblyAI, Speechmatics, Gladia, Rev.ai, AWS Transcribe, Cartesia, Murf, Hume, Resemble, Inworld). Aggregator - OpenRouter, SiliconFlow. The ElevenLabs and Deepgram Prime promotions are the user's call: "maybe 1 or 2 are Prime." + - Normalize the knob, never the settings: `effort_levels` holds the provider's own level names as strings (observed union: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); no cross-provider ordinal scale is invented. This matches the existing `Capabilities.effort_levels: Vec` in `crates/gateway-config/src/config.rs`. + - The sheet includes optional `pricing` and `deprecation` fields, filled where the provider's list endpoint reports them (pricing: xAI, DashScope-native, Baidu, Perplexity router, OpenRouter; deprecation: Bedrock, Mistral, Cohere, OpenRouter). Pricing normalizes to per-million-token units with an explicit currency field. + - Schema design does not require API keys: every provider's list-response shape was extracted from official documentation on 2026-09-14, and three live payloads (Anthropic, OpenRouter, NVIDIA) were verified directly. Keys are for ongoing freshness at workflow time, not for designing the normalizer. + - Phase 1 is infrastructure only - crates, binary, workflow, artifact - with no UI changes and no Gateway consumption: the user's words - "I don't want anything changed in the UI yet. First I want to get the infrastructure in place and reliable to build the table." + - The sheet covers media models, not just chat: image generation, speech-to-text, and text-to-speech providers are in scope per the user's directive. `ModelEntry` gains a `kind` field, and the hoisted `ModelKind` extends beyond its current chat/embedding/classifier/speech set with `transcription`, `image`, and `video` variants (the gateway's wire already knows `transcription` for STT catalog entries). + - Midjourney and PlayHT are excluded: Midjourney has no official public API, and PlayHT is defunct (Meta acquisition, sunset 2025-12-31). + - Hoist, not mirror: `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` move into `shared-gateway-api` as their canonical home, with `gateway-config` and `gateway-protocol` re-importing: the user's words - "hoist for sure." A mirrored parallel definition is exactly the parallel-truth debt the repo's debt-collector passes keep cleaning up. + - `Provider` is a new concept, distinct from `EndpointConfig`: a case-insensitive grep for "provider" across `gateway-config`, `gateway-protocol`, and `gateway-routing` returns zero matches; what the TOML reflects is `EndpointConfig` (`crates/gateway-config/src/config.rs` lines 468-483), an operator-configured endpoint instance holding a live `Secret` and an optional dominion binding, covering any OpenAI-compatible backend. `Provider` is a static vendor descriptor in code - no secrets, no operator choices, just name, default base URL, and API-key env-var name. A future `[[endpoint]]` may reference a provider for its defaults, but that is unification potential, not identity. + - `generated_at` is RFC 3339 with a literal `Z`: every consumer stack parses it natively; the workspace's existing `time` 0.3 dependency (`Cargo.toml`) needs only its `parsing` feature enabled. + - Raw payload embedding: rejected (user decision 2026-09-14). Model entries carry normalized fields only; the artifact stays small and schema-stable, and provider-specific response shapes never leak into consumers. + - First-iteration scope is the Prime tier only (user decision 2026-09-14): Anthropic, OpenAI, Google Gemini, xAI, DeepSeek, Alibaba Qwen, Moonshot AI, Meta, ElevenLabs, Deepgram. Subprime providers, Niche static lists, and Aggregators follow once the Prime pipeline is proven. The schema and `build_sheet` keep `static` slice support, but no Niche provider files ship in v1. + - Schedule cadence: weekly cron plus manual dispatch (user decision 2026-09-14). +- Rejected alternatives: + - The crate as CI-only tooling (a build binary run by the workflow): superseded by the user's correction that the crate links into the Gateway. The workflow running the crate's `bin` target was later settled by the double-duty decision. + - Replacing the gateway's runtime types with the sheet types outright: rejected because the nullability regimes differ - the sheet is best-effort (`context_window: Option` because IDs-only providers omit it) while the runtime enforces validated configuration (`ModelConfig.context` is a required `u32`); `ModelInfo` is also a stable wire contract that Workshop's dropdown already parses. `ModelKind` is the exception: it is shared outright, and the sheet's `kind` field uses it. The user approved this reasoning: "this makes sense." + - LLM inference over provider docs pages inside the publish workflow: rejected because the sheet is consumed as authoritative and LLM extraction introduces silent nondeterminism; a hallucinated context window is worse than an absent one. The fields it would fill are covered by static curation in the provider file. + - Removing the `gateway` crate's lib target as extraneous (no downstream crate links it): rejected because the lib is the integration-test seam - the 30-file suite under `crates/gateway/tests/it/` imports the crate through its lib target, and the crate dev-depends on itself with the `test-fixtures` feature for exactly that reason. Revisit never. + - Mirroring the model-metadata types in `shared-gateway-api` while leaving the originals in place: rejected in favor of hoisting; parallel definitions of `Capabilities` would drift. Revisit never. +- Assumptions, risks, and notes: + - GitHub Actions runners have unrestricted outbound HTTPS; vendor endpoints are reachable from workflows with curl or any HTTP client. + - The Anthropic `GET /v1/models` response shape (verified live 2026-09-14) contains `id`, `display_name`, `created_at`, `max_input_tokens`, `max_tokens`, and a `capabilities` object; it has no pricing and no deprecation status. + - Anthropic's docs publish a keyless markdown mirror of the models overview page; other providers may lack an equivalent, which is part of the case for key-backed aggregation. + - UI consumption evidence (2026-09-14 survey): the Workshop model dropdown uses only `id` and `description` (`crates/workshop-server/ui/src/services/protocol.ts` lines 25-34, `ui/src/ui/chrome/model-picker-trigger.ts` lines 69-99); the config UI's models view consumes `kind`, `description`, `context`, `thinking`, and the flattened capability keys (`crates/gateway-config-ui/ui/src/views/models-view.ts`, `ui/src/components/settings-registry.ts` lines 89-196). The hoisted field set covers both consumers. + +### Deferred and Out of Scope + +- Deferred: the Gateway's sheet-consumption path (`fetch_sheet`, cache, config-UI model choices) and all UI integration. The user's words: "I don't want anything changed in the UI yet. First I want to get the infrastructure in place and reliable to build the table." Revisit when the workflow has produced reliable sheets. Phase 2 should name a gateway route that re-serves the normalized catalog, so hosts consume it from the Gateway rather than fetching from GitHub themselves. +- Deferred: an LLM-assisted curation bot that reads provider docs and opens PRs proposing updates to the static lists - LLM leverage with a human gate, keeping the published artifact deterministic. Revisit when the static lists need their first refresh. +- Deferred: Workshop server linking `shared-gateway-api` directly for the model dropdown. The dropdown already works through the Gateway's catalog; the direct link only matters when the UI wants richer per-provider data than the catalog carries. Revisit when the dropdown needs tier or per-provider metadata. +- Deferred: consolidating the gateway's per-upstream trio of role-specific `reqwest::Client`s into a shared client (reqwest's per-request `timeout()` makes it possible; the gain is marginal because connection pooling is per-host, and the SSE timeout behavior carries regression risk). Revisit when upstream client construction is otherwise touched. +- Deferred: a max-staleness eviction policy for `stale` provider slices, which keep advertising a model if a provider retires it while its fetches keep failing. Revisit when a provider retirement collides with a fetch outage. +- Out of scope: changes to the gateway's upstream client construction beyond the one bounded client built for sheet downloads. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build` (builds only the gateway, the default member, on a fresh clone); the desktop app is explicit: `cargo build -p workshop` +- Focused test command pattern: `cargo nextest run -p ` +- Component test command pattern: `cargo nextest run -p ` +- Full-suite test command: `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 command: `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 check command: `cargo fmt --all --check` +- Docs command: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server` with `RUSTDOCFLAGS="-D warnings"`; user guide: `mdbook build guide` +- Test placement and naming conventions: unit tests live in `#[cfg(test)]` modules beside the source; integration tests live in `crates//tests/` (present in gateway, promptforge-api, workshop-server, and about a dozen other crates); JavaScript tools in `tools/` carry sibling `*.test.mjs` files; nextest profiles and a `heavy` test group for tensor/FFI suites are configured in `.config/nextest.toml`; the boundary and structural harness runs as `cargo test -p build-xtask` +- Directory map: `crates/` holds all workspace members (Rust crates plus the excluded TypeScript package `shared-ui`); `guide/` is the mdBook user guide; `prompts/` holds example PromptForge prompt files; `tools/` holds standalone JS tools and docs; `vibe/` holds design and plan documents including `archdoc.md`; `.github/workflows/` holds CI; `.config/` holds nextest config; `.githooks/`, `.cargo/`, `images/`, `local/`, `target/`, and `target-msrv/` are support and build output +- Component boundaries: three products with strict naming and dependency rules: `promptforge-*` (executor, parser, Lua boundary, store, VFS policy, web tools; may not depend on gateway or workshop crates), `gateway-*` (inference gateway: routing, protocol, config, STT, sidecar; may not depend on promptforge or workshop crates), `workshop-*` (Tauri desktop shell and in-process server; may not depend on gateway crates); `shared-*` crates carry the cross-product API surface and depend on no product crates; `build-*` crates build specific outputs; the one-door rule: crates outside the promptforge-* family may depend only on `promptforge-api`, never on internal promptforge-* substrate crates; dependency direction is shell -> features -> services -> vocabulary, enforced by `build-xtask` +- Conventions summary: edition 2024, workspace-inherited lints forbid unsafe code and deny clippy `all`, `unwrap_used`, and `expect_used`; behavior changes ship with tests in the same change; reuse of existing facilities is preferred over new machinery; error messages are written for model consumption (concise, factual, self-contained); no file exceeds 500 lines; every workshop-* crate's lib.rs opens with a `## Invariants` doc marker; SPA CSS lives beside its TypeScript with `--ws-*` design tokens, never raw values; long-running work reports through `shared-progress`; Cargo features gate real constraints, not product shape + + + + +## Execution Instructions + +Decomposition (Path: FULL), three components in dependency order: + +1. `shared-gateway-api` first: both other components depend on its schema types, and the hoist must land before `shared-cloud-providers` can reference the canonical `ModelKind`. +2. `shared-cloud-providers` second: the workflow compiles its `bin` target, so the crate must exist and be green first. +3. `aggregation-workflow` last: it only wires the binary into GitHub Actions and cannot be verified before the binary exists. + +Pieces build sequentially within each component: the schema precedes the hoist so each commit compiles on its own (the schema is purely additive; the hoist touches existing crates); the fetch seam precedes the provider files that plug into it; `build_sheet` follows the provider files it aggregates; the binary follows the lib it wraps. + + + +### Step 1: shared-gateway-api sheet schema + +- Component: shared-gateway-api +- Create `crates/shared-gateway-api/` (Cargo.toml, `src/lib.rs`), edition 2024, workspace lints, depending only on `serde` and `time` (with its `parsing` feature), mirroring the `shared-promptforge-api` precedent. +- Declare the sheet schema types exactly as specified in the implementation contract: `Sheet`, `ProviderSlice`, `Tier`, `SliceStatus`, `ModelEntry`, `Thinking`, `Pricing`, `Deprecation`. +- Tests: schema round-trip (serialize, parse, compare); `BTreeMap` provider ordering is byte-deterministic; `generated_at` serializes as RFC 3339 with a literal `Z`; the contract's example JSON parses into the schema. + + + + + +### Step 2: hoist model-metadata types into shared-gateway-api + +- Component: shared-gateway-api +- Move `Capabilities`, `ModelInfo`, `ModelKind`, and `ThinkingMode` from `crates/gateway-config/src/config.rs` and `crates/gateway-protocol` into `shared-gateway-api` as their canonical home; extend `ModelKind` with the `transcription`, `image`, and `video` variants. +- Re-export all four types at their old paths in `gateway-config` and `gateway-protocol` so downstream call sites compile unchanged; add the `shared-gateway-api` dependency to both crates. +- Tests: the workspace compiles with no call-site edits; existing gateway and workshop suites stay green, proving the hoist. + + + + + +### Step 3: shared-cloud-providers scaffold and fetch seam + +- Component: shared-cloud-providers +- Create `crates/shared-cloud-providers/` (Cargo.toml with `lib` and `bin` targets, `src/lib.rs`), depending on `shared-gateway-api` and `reqwest`. +- Declare the public `Provider` descriptor (`name`, `display_name`, `tier`, `key_env`, `base_url`), the `providers()` registry, `FetchError`, and the `fetch_models(client, provider, key)` signature with the injected `reqwest::Client` seam, exactly as specified in the implementation contract. +- Tests: registry entries have unique names and unique `key_env` values; every Prime-tier descriptor carries the tier, key-env, and base URL settled in the decision record. + + + + + +### Step 4: anthropic provider file + +- Component: shared-cloud-providers +- Add `src/providers/anthropic.rs`: public `Provider` descriptor plus private variance - `x-api-key` and required `anthropic-version` headers, cursor pagination, and normalization of the verified response shape (`id`, `display_name`, `created_at`, `max_input_tokens`, `max_tokens`, `capabilities`) into `ModelEntry`. +- Register the provider in `providers()`. +- Tests: normalization against the recorded 2026-09-14 live Anthropic payload as a fixture; pagination across a two-page fixture; capability and thinking-flag mapping. + + + + + +### Step 5: OpenAI-dialect provider files + +- Component: shared-cloud-providers +- Add `openai.rs`, `xai.rs`, `deepseek.rs`, `qwen.rs`, `moonshot.rs`, and `meta.rs`, sharing one private helper for the OpenAI response shape; per-file variance covers xAI's `aliases`/`context_length`/pricing (normalized from USD cents per 100M to per-million-token), Moonshot's `context_length` and image/video/reasoning flags, and DashScope's compatible-mode endpoint. +- Register all six in `providers()`. +- Tests: per-provider normalization against documented example responses as fixtures; pricing unit normalization for xAI; IDs-only providers emit `None` for `context_window` and `max_output`. + + + + + +### Step 6: gemini provider file + +- Component: shared-cloud-providers +- Add `src/providers/gemini.rs`: public descriptor plus private variance - `?key=` query param or `x-goog-api-key` header, `pageToken` pagination on `GET /v1beta/models`, and normalization of `inputTokenLimit`, `outputTokenLimit`, `supportedGenerationMethods`, and the thinking flag. +- Register the provider in `providers()`. +- Tests: normalization against documented example responses as fixtures; `pageToken` traversal; generation-method to capability-boolean mapping. + + + + + +### Step 7: media provider files (elevenlabs, deepgram) + +- Component: shared-cloud-providers +- Add `elevenlabs.rs` and `deepgram.rs`: ElevenLabs uses the `xi-api-key` header and its rich list response (languages, capabilities, rates); Deepgram uses the `Authorization: Token` prefix and splits its STT models and TTS array into separate `ModelEntry` values. +- Both files set `ModelEntry.kind` to `transcription` or `speech` (and `image` where applicable), exercising the extended `ModelKind`. +- Register both in `providers()`. +- Tests: per-provider normalization against documented example responses as fixtures; STT and TTS entries from one Deepgram payload carry distinct kinds. + + + + + +### Step 8: build_sheet, fetch_sheet, and propagation + +- Component: shared-cloud-providers +- Implement `build_sheet(client, previous, keys)`: per provider, a successful fetch writes a fresh `ok` slice with `fetched_at` = now; a failed fetch copies the previous slice verbatim with `status` rewritten to `stale` and its original `fetched_at` preserved; a failed fetch with no previous slice records `unavailable` with an empty `models` array; `static` slice support is present for Niche providers though no Niche provider files ship in v1. Assemble the envelope (`schema_version` 1, `generated_at` = now). +- Implement `fetch_sheet(client, release_url)` for downloading and parsing the release artifact. +- Tests: the full propagation matrix (`ok`, `stale` with preserved `fetched_at`, `unavailable`, `static` via a test-only static provider); a failed fetch never fails the build and never drops data. + + + + + +### Step 9: sheet-building binary + +- Component: shared-cloud-providers +- Add `src/main.rs`, a thin `main` over the lib: read provider keys from environment variables (names from each descriptor's `key_env`), download the previous release's `models.json` when it exists (tolerate its absence on first run), call `build_sheet`, and write the merged `models.json`. +- Tests: an integration test under `crates/shared-cloud-providers/tests/` runs the binary against recorded fixtures and validates the output parses as a schema-valid `Sheet`. + + + + + +### Step 10: aggregation workflow + +- Component: aggregation-workflow +- Add one workflow file under `.github/workflows/`: triggers on `workflow_dispatch` and a weekly cron; compiles the `shared-cloud-providers` binary; runs it with provider keys injected from GitHub secrets as environment variables matching each descriptor's `key_env`; publishes the resulting `models.json` as a release artifact in the promptforge repo, replacing the previous release's asset so `fetch_sheet` has a stable URL. +- Include a check that the emitted sheet contains no secret material. +- Verification: a manual dispatch produces and publishes a schema-valid artifact; last-known-good propagation is exercised by a provider with a missing key appearing as `stale` or `unavailable`, never as a build failure. + + + +Phase 2 (the Gateway's sheet-consumption path and config-UI integration) is deferred and is not execution scope for this plan. + + diff --git a/vibe/ACTIVE b/vibe/ACTIVE new file mode 100644 index 00000000..15c38e0a --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +vibe/2026-09-14-2-provider-model-sheets.md \ No newline at end of file