Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d7a051f
Honor userdata __pairs in sandbox pairs
vinniefalco Oct 9, 2026
d7a9d0e
Add the Rust-backed MessageList core
vinniefalco Oct 9, 2026
73c0091
Add read-only record views and iteration to MessageList
vinniefalco Oct 9, 2026
2e15892
Back messages.new() with the Rust MessageList
vinniefalco Oct 9, 2026
a859380
Record after and keep on Chat effects
vinniefalco Oct 9, 2026
8ac23f4
Close plan: messages-userdata-refactor
vinniefalco Oct 9, 2026
b5ea5d9
Fix models.loop compactor error and bound list pairs
vinniefalco Oct 9, 2026
9ca6146
Give model handles loop and infer methods
vinniefalco Oct 10, 2026
5a676d1
Record the guide update for handle methods
vinniefalco Oct 10, 2026
7da8ff2
Pin models.loop behavior with contract tests
vinniefalco Oct 10, 2026
c0a7248
Run models.loop rules in a Rust state machine
vinniefalco Oct 10, 2026
04266ff
Fold the task-notice drain into the chat dispatch
vinniefalco Oct 10, 2026
e496183
Resume the chat answer as an opaque ChatResult
vinniefalco Oct 10, 2026
2a8e9ae
Close plan: models-loop-in-rust
vinniefalco Oct 10, 2026
790b507
Keep shim raises working when author code rebinds error
vinniefalco Oct 10, 2026
62ee95b
Drop model and metrics from ChatResult and fix stale docs
vinniefalco Oct 10, 2026
2b53936
Close plan: models-loop-debt-removal
vinniefalco Oct 10, 2026
da5b2f8
Stop installing role labels as Lua globals
vinniefalco Oct 10, 2026
20d879a
Drop tool slots from the Workshop contract and run panel
vinniefalco Oct 10, 2026
4dc463c
Name tools by canonical id and remove tool slots
vinniefalco Oct 10, 2026
0ea5e77
Add the plugins table over shared plugin objects
vinniefalco Oct 10, 2026
4e2f7ce
Report an optional model provider in the gateway catalog
vinniefalco Oct 10, 2026
dc08e06
Expose the model provider on model handles
vinniefalco Oct 10, 2026
70f7fd5
Close plan: author-api-reshape
vinniefalco Oct 10, 2026
445bb2a
Run the docs-claims wording scan from local gates
vinniefalco Oct 10, 2026
86ce588
Resolve model tool names by wire name only
vinniefalco Oct 10, 2026
74f7ff9
Rename Lua tool internals to match the offer API
vinniefalco Oct 10, 2026
8cac6f0
Describe ToolPerformer alias as wire name or canonical id
vinniefalco Oct 10, 2026
abe218a
Close plan: author-api-debt-removal
vinniefalco Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
# Pre-push hook: Comprehensive validation before pushing to remote.
set -e

echo "==> Running docs-claims wording scan..."
node --test crates/workshop/ui/test/docs-claims.mjs

echo "==> Checking headless gateway (AGENTS.md rule)..."
cargo check -p gateway --no-default-features

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Four words have exactly one meaning each, everywhere in this repository: code co
- Facade docs: `RUSTDOCFLAGS="-D warnings" cargo doc -p promptforge --no-deps`, without `--all-features`, so the facade's docs build with default features.
- Facade surface: `cargo +<pinned nightly> xtask api --check`, where the pinned nightly is the one named in `crates/build-xtask/src/api/toolchain.rs`; on any other toolchain it fails at once, naming the nightly it needs. It checks that every path a surface item's signature, fields, bounds, impls, or doc links name is a facade re-export (or std, core, alloc, or an allowlisted crate), that no surface doc text names an internal crate, and that the surface listing matches the committed `crates/promptforge/public-api.txt`.
- Boundary and structural checks: `cargo test -p build-xtask`. It enforces the product and container boundaries, the Workshop tier graph, the `## Invariants` marker, and lint inheritance.
- Wording and rulebook scan: `node --test crates/workshop/ui/test/docs-claims.mjs`. It enforces the Engine wording rule, the rulebook Definitions, and the Plugin lifecycle wording (installed, snapshotted, or declared). It needs no `npm ci` and runs from any directory, because the file resolves the repository root from its own path.

## Structural Rules

Expand Down
5 changes: 5 additions & 0 deletions crates/gateway-api-types/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ pub struct Capabilities {
/// Empty means the model exposes no fixed voice list.
#[serde(default)]
pub voices: Vec<String>,
/// The model's provider id, such as `xai`. It describes the model, not
/// the endpoint the gateway reaches it through, and uses only lowercase
/// letters, digits, `.`, `_`, and `-`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
}

impl Capabilities {
Expand Down
56 changes: 56 additions & 0 deletions crates/gateway/app/tests/it/chat/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,62 @@ async fn models_catalog_omits_unset_optional_capabilities() {
gateway.shutdown().await;
}

#[tokio::test]
async fn models_catalog_reports_a_provider_only_when_configured() {
let backend = fake_backend().await;
let toml = format!(
r#"
config-version = 0

[server]
bind = "127.0.0.1:0"
api_key = "test-token"

[[endpoint]]
id = "fake"
protocol = "openai"
base_url = "http://{backend}"
api_key = ""

[[model]]
name = "grok"
description = "a model with a provider"
context = 8192
upstream = "backend-model"
endpoints = ["fake"]
provider = "xai"

[[model]]
name = "plain"
description = "a model without a provider"
context = 8192
upstream = "backend-model"
endpoints = ["fake"]
"#
);
let config = Config::from_toml_str(&toml).unwrap();
let gateway = Gateway::from_config(&config, ProfilesContext::default()).unwrap();
let gateway = TestServer::start(gateway).await;

let response = send_within(
reqwest::Client::new()
.get(format!("http://{}/v1/models", gateway.addr))
.bearer_auth("test-token"),
)
.await;
assert_eq!(response.status().as_u16(), 200);

let body = json_within(response).await;
let data = body.get("data").and_then(Value::as_array).unwrap();
assert_eq!(data.len(), 2);
assert_eq!(data[0].get("id").and_then(Value::as_str), Some("grok"));
assert_eq!(data[0].get("provider").and_then(Value::as_str), Some("xai"));
assert_eq!(data[1].get("id").and_then(Value::as_str), Some("plain"));
// An unset provider is omitted, never serialized as null.
assert!(!data[1].as_object().unwrap().contains_key("provider"));
gateway.shutdown().await;
}

#[tokio::test]
async fn models_catalog_wrong_token_is_401() {
let backend = fake_backend().await;
Expand Down
43 changes: 43 additions & 0 deletions crates/gateway/config/src/config/tests/validation/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,49 @@ fn capabilities_default_to_absent() {
assert!(!capabilities.adaptive_thinking());
}

#[test]
fn parses_an_optional_provider_on_models_and_local_models() {
for provider in ["xai", "x.ai", "open_router", "mistral-ai", "gpt4"] {
let extra = format!("provider = {provider:?}");
let config = Config::from_toml_str(&catalog_with_model_kind("chat", &extra)).unwrap();
assert_eq!(
config.models()[0].capabilities().provider.as_deref(),
Some(provider)
);
let config = Config::from_toml_str(&catalog_with_local_model_kind("chat", &extra)).unwrap();
assert_eq!(
config.local_models()[0].capabilities().provider.as_deref(),
Some(provider)
);
}
let config = Config::from_toml_str(&catalog_with_model_kind("chat", "")).unwrap();
assert_eq!(config.models()[0].capabilities().provider, None);
let config = Config::from_toml_str(&catalog_with_local_model_kind("chat", "")).unwrap();
assert_eq!(config.local_models()[0].capabilities().provider, None);
}

#[test]
fn rejects_a_provider_outside_lowercase_letters_digits_and_separators() {
for provider in ["", "xAI", "x ai", "x/ai", "x:ai"] {
let extra = format!("provider = {provider:?}");
for (toml, expected) in [
(
catalog_with_model_kind("chat", &extra),
"model m provider must use lowercase letters, digits, '.', '_', or '-'",
),
(
catalog_with_local_model_kind("chat", &extra),
"local_model q provider must use lowercase letters, digits, '.', '_', or '-'",
),
] {
match Config::parse_toml(&toml) {
Err(ConfigError::Validation(message)) => assert_eq!(message, expected),
other => panic!("expected provider {provider:?} to be refused, got {other:?}"),
}
}
}
}

#[test]
fn rejects_default_effort_without_effort_levels() {
let toml = catalog_with_model_kind(
Expand Down
20 changes: 18 additions & 2 deletions crates/gateway/config/src/config/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ impl Config {
///
/// `default_effort` requires a non-empty `effort_levels` and must name a
/// listed level; the effort knobs are meaningless on a model that never
/// thinks; `max_output` must fit the context window; and `voices` entries
/// must be non-empty and unique.
/// thinks; `max_output` must fit the context window; `voices` entries
/// must be non-empty and unique; and `provider` must match `[a-z0-9._-]+`.
fn validate_capabilities(
label: &str,
name: &str,
Expand Down Expand Up @@ -255,9 +255,25 @@ fn validate_capabilities(
)));
}
}
if let Some(provider) = &capabilities.provider
&& !is_valid_provider(provider)
{
return Err(ConfigError::Validation(format!(
"{label} {name} provider must use lowercase letters, digits, '.', '_', or '-'"
)));
}
Ok(())
}

/// Whether `value` is a non-empty run of lowercase ASCII letters, digits,
/// `.`, `_`, and `-`.
fn is_valid_provider(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
})
}

/// Rejects chat-only fields on a non-chat model kind and the speech-only
/// `voices` list on a non-speech kind.
///
Expand Down
33 changes: 33 additions & 0 deletions crates/harness-gateway-client/src/catalog-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,39 @@ async fn fetch_model_catalog_skips_entries_without_a_context_window() {
assert_eq!(chat.thinking(), ThinkingMode::Never);
}

#[tokio::test]
async fn fetch_model_catalog_reads_a_provider_only_when_the_entry_names_one() {
use axum::Router;
use axum::routing::get;

async fn models() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
"object": "list",
"data": [
{ "id": "grok", "object": "model", "kind": "chat", "description": "d",
"context": 4096, "thinking": "never", "provider": "xai" },
{ "id": "local", "object": "model", "kind": "chat", "description": "d",
"context": 4096, "thinking": "never" }
]
}))
}
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("an optional provider decodes");
let provider = |name: &str| {
catalog
.get(&ModelId::gateway(name).expect("valid id"))
.expect("the model is in the catalog")
.provider()
.map(str::to_owned)
};
assert_eq!(provider("grok").as_deref(), Some("xai"));
assert_eq!(provider("local"), None);
}

#[tokio::test]
async fn fetch_model_catalog_still_rejects_a_zero_context_window() {
use axum::Router;
Expand Down
14 changes: 8 additions & 6 deletions crates/harness-gateway-client/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ struct ModelsListEntry {
context: Option<u32>,
#[serde(default)]
thinking: Option<ThinkingMode>,
/// The model's provider id, when the gateway names one.
#[serde(default)]
provider: Option<String>,
}

/// Wire shape of gateway `GET /v1/models`.
Expand Down Expand Up @@ -200,12 +203,11 @@ pub async fn fetch_model_catalog(
malformed("a model declares a context window but no thinking mode")
.with_detail(escape_controls(id.name(), MAX_CATALOG_ERROR_BODY))
})?;
descriptors.push(ModelDescriptor::new(
id,
entry.description,
context,
thinking,
));
let descriptor = ModelDescriptor::new(id, entry.description, context, thinking);
descriptors.push(match entry.provider {
Some(provider) => descriptor.with_provider(provider),
None => descriptor,
});
}
ModelCatalog::new(descriptors).map_err(|error| {
malformed("gateway returned an inconsistent model catalog")
Expand Down
32 changes: 18 additions & 14 deletions crates/harness-gateway-client/src/wire/request-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,24 @@ type Offered = (&'static str, &'static str, Value);
/// Drives a facade `Run` through one tool round and returns what its second
/// `Chat` effect sent.
///
/// The section adds every tool in `offered` and runs `models.loop` over one
/// user message. The first `Chat` effect is answered with a call to the
/// The section offers every tool in `offered` as `example/test/<name>`, so
/// the model sees it as `example_test_<name>`, and runs `models.loop` over
/// one user message. The first `Chat` effect is answered with a call to the
/// first tool, `call_1` with `{"value":"one"}`; the tool call with the
/// trusted output `echoed: one`; and the second `Chat` effect, recorded
/// here, with the text `done`.
fn tool_round(offered: &[Offered]) -> Round {
let (mut slots, mut adds) = (String::new(), String::new());
let mut offers = String::new();
for (name, _, _) in offered {
writeln!(slots, " {name}: example/test/{name}").expect("a String takes every write");
writeln!(adds, "tools.add('{name}')").expect("a String takes every write");
writeln!(offers, "tools.offer('example/test/{name}')").expect("a String takes every write");
}
let source = format!(
"---\nname: round\ndescription: One tool round.\npromptforge: 0\n\
models:\n writer: {{}}\ntools:\n{slots}---\n\n# Round\n\n## Ask\n\n\
```lua\nmodels.use('writer')\n{adds}local msgs = messages.new()\n\
models:\n writer: {{}}\n---\n\n# Round\n\n## Ask\n\n\
```lua\nmodels.use('writer')\n{offers}local msgs = messages.new()\n\
msgs:user('echo once')\nmodels.loop(msgs)\nreturn msgs[#msgs].content\n```\n"
);
let first_wire_name = format!("example_test_{}", offered[0].0);
let (parsed, _parse_events) = Prompt::parse(&source, "round");
let prompt = parsed.expect("the round prompt parses");
let descriptors: Vec<ToolDescriptor> = offered
Expand Down Expand Up @@ -85,9 +86,12 @@ fn tool_round(offered: &[Offered]) -> Round {
..
} => {
let reply = if rounds.is_empty() {
let call =
ToolCall::from_parts("call_1", offered[0].0, json!({ "value": "one" }))
.expect("a whole call");
let call = ToolCall::from_parts(
"call_1",
first_wire_name.as_str(),
json!({ "value": "one" }),
)
.expect("a whole call");
CompletionResult::ToolCalls(vec![call])
} else {
CompletionResult::Text("done".to_owned())
Expand Down Expand Up @@ -196,7 +200,7 @@ fn a_replayed_tool_call_turn_serializes_in_the_openai_function_shape() {
let call = &body["messages"][1]["tool_calls"][0];
assert_eq!(call["id"], "call_1");
assert_eq!(call["type"], "function");
assert_eq!(call["function"]["name"], "echo");
assert_eq!(call["function"]["name"], "example_test_echo");
assert_eq!(call["function"]["arguments"], "{\"value\":\"one\"}");
assert_eq!(body["messages"][2]["role"], "tool");
assert_eq!(body["messages"][2]["tool_call_id"], "call_1");
Expand Down Expand Up @@ -288,15 +292,15 @@ fn each_tool_is_wrapped_as_a_function_with_its_schema_in_order() {
{
"type": "function",
"function": {
"name": "echo",
"name": "example_test_echo",
"description": "Echo a value.",
"parameters": parameters,
},
},
{
"type": "function",
"function": {
"name": "grab",
"name": "example_test_grab",
"description": "Grab a value",
"parameters": { "type": "object" },
},
Expand Down Expand Up @@ -346,7 +350,7 @@ fn options_and_tools_reach_the_body() {
assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false);
assert_eq!(body["tool_choice"], "auto");
assert_eq!(body["tools"][0]["type"], "function");
assert_eq!(body["tools"][0]["function"]["name"], "echo");
assert_eq!(body["tools"][0]["function"]["name"], "example_test_echo");
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/harness-internal/runner/src/effect_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ impl Driver {
tools,
options,
round,
..
} => {
let broker = Arc::clone(&self.performers.broker);
let round = async move {
Expand Down
20 changes: 3 additions & 17 deletions crates/harness-internal/runner/src/host-run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ use std::sync::Arc;

use promptforge::effect::ToolCallOrigin;
use promptforge::plugins::Prelude;
use promptforge::prompt::ToolSlot;
use promptforge::tools::{
ToolCatalog, ToolDescriptor, ToolError, ToolErrorKind, ToolId, ToolOutput,
};
Expand Down Expand Up @@ -126,24 +125,11 @@ impl HostRunContext {
}

/// What the snapshot cannot meet for `prompt`, for each Plugin it
/// declares or names in a tool slot: one not installed, one that
/// failed to build, and one whose needs the run lacks.
/// declares: one not installed, one that failed to build, and one
/// whose needs the run lacks.
pub(super) fn requirements(&self, prompt: &Prompt) -> Requirements {
let frontmatter = prompt.frontmatter();
let slotted = frontmatter.tools().iter().filter_map(|(_alias, slot)| {
let ToolSlot::Exact(tool) = slot else {
return None;
};
Some(tool.plugin())
});
let mut named: Vec<PluginId> = Vec::new();
for name in frontmatter.plugins().iter().cloned().chain(slotted) {
if !named.contains(&name) {
named.push(name);
}
}
let mut requirements = Requirements::default();
for name in named {
for name in prompt.frontmatter().plugins().iter().cloned() {
match self.plugins.get(&name) {
None => requirements.missing_required.push(name),
Some(RunPlugin::Unavailable(reason)) => requirements
Expand Down
Loading
Loading