Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7357dd2
Add the vibelight cargo alias
vinniefalco Oct 27, 2026
0880115
Add the std Pack with Cursor's six file tools
vinniefalco Oct 27, 2026
c73db8f
State the gateway's standard-first API principle
vinniefalco Oct 28, 2026
beb5a43
Fold the Pack roster and vendors into one map
vinniefalco Oct 29, 2026
7a5341b
Derive Chat options from the binding, not a copied field
vinniefalco Oct 29, 2026
a8a7a6e
Make VM access infallible and carry argv in setup
vinniefalco Oct 29, 2026
1dbdbf7
Single-source the Pack order and type the tool-set evidence
vinniefalco Oct 29, 2026
b219b85
Give root starts one constructor and rename admission state
vinniefalco Oct 29, 2026
4ccfc86
Single-source the empty reply and narrow test support
vinniefalco Oct 29, 2026
7fce60f
Align engine doc comments with runtime behavior
vinniefalco Oct 29, 2026
8ad9cee
Name the import surfaces and correct the section docs
vinniefalco Oct 29, 2026
9d7b44f
Correct the effect list and store-access rule
vinniefalco Oct 29, 2026
4eb4446
Close plan: engine vibelight cleanup
vinniefalco Oct 29, 2026
0c2477a
Delete the instruction quota and correct Lua error docs
vinniefalco Oct 29, 2026
72809ff
Seed counts on increment and rename tool and model state
vinniefalco Oct 29, 2026
4ba412e
Split tool and section modules and narrow tool dispatch
vinniefalco Oct 29, 2026
099f311
Make Answer success-only and tidy the protocol types
vinniefalco Oct 29, 2026
e9ee6cb
Unify shim install and tighten the tool-call shape
vinniefalco Oct 29, 2026
18cf199
Carry compaction snapshots in loop phases; correct docs
vinniefalco Oct 29, 2026
ab68ce2
Move store reads and key order into their own modules
vinniefalco Oct 29, 2026
9b4c21a
Close plan: vibelight lua slop cleanup
vinniefalco Oct 29, 2026
8e763fe
Store tool call arguments as a map and narrow wire fields
vinniefalco Oct 29, 2026
7f78e14
Fold model options into one invocation and correct docs
vinniefalco Oct 29, 2026
157bc77
Close plan: model-client slop cleanup
vinniefalco Oct 29, 2026
4e6a266
Tighten engine invariants, names, visibility, and docs
vinniefalco Oct 30, 2026
8064ff7
Close plan: engine vibelight cleanup
vinniefalco Oct 30, 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
4 changes: 4 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ rustflags = ["-C", "target-feature=+crt-static"]
[alias]
workshop = "run -p build-workshop --"
xtask = "run -p build-xtask --"
# Outlines the workspace with the vibelight checkout next to this repo. Cargo
# builds it in release mode on first use and rebuilds it when its source
# changes. Run it from the repo root: the path is relative to the cwd.
vibelight = "run --release --manifest-path ../vibelight/Cargo.toml --"

# Clippy appends a docs-link line to every diagnostic; the lint name already
# identifies it.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Multi-crate Rust workspace for the Engine, the Harness that runs it, the Gateway

Seven terms have exactly one meaning each, everywhere in this repository: code comments, docs, rulebooks, and plans. Write them capitalized.

- **Engine**: the `runvil` and `runvil-*` crates. The Engine parses a prompt and steps a run. Whenever a run needs a model reply, a Pack's render of the tools a section offers it, a catalog tool's result, a timer, or a file outside a prelude's install, the Engine emits an effect and waits for its caller to answer it. In production the caller is the Harness.
- **Engine**: the `runvil` and `runvil-*` crates. The Engine parses a prompt and steps a run. Whenever a run needs a model reply, a Pack's render of the tools a section offers it, a Pack tool's result, a timer, or a file outside a prelude's install, the Engine emits an effect and waits for its caller to answer it. In production the caller is the Harness.
- **Harness**: the `harness` and `harness-*` crates. The Harness steps the Engine, performs every effect, returns each answer, and records every run through the recorder the Host supplies. Production code runs prompts only through the Harness.
- **Host**: an application that runs prompts through the Harness, such as Workshop or Papergate. The Host makes every policy decision. It runs prompts only through the Harness; it may also use the Engine's parser and types to read prompts and show events.
- **Pack**: code which installs an optional native prelude, an optional Lua prelude, and zero or more tools.
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ harness-runner = { path = "crates/harness-internal/runner" }
harness-plugin-loader = { path = "crates/harness-internal/plugin-loader" }
plugin-user-input = { path = "crates/plugin-user-input" }
plugin-web = { path = "crates/plugin-web" }
plugin-std = { path = "crates/plugin-std" }
plugin-mcp = { path = "crates/plugin-mcp" }
gateway-routing = { path = "crates/gateway/routing" }
runvil-lua = { path = "crates/runvil-internal/lua" }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The frontmatter declares one model role, `writer`, and the Lua under the title m

## How it fits together

The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a tool result, a timer, or a file, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness.
The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a Pack's render of the tools a section offers, a Pack tool's result, a timer, or a file outside a prelude's install, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness.

The gateway is the one process that holds model credentials, so no other process ever sees a vendor key. It serves an OpenAI-compatible API and routes each request to a cloud provider or to a model running on your own machine. It also provides web search, speech-to-text, text-to-speech, and a settings page in your browser, and you can run it on its own, on a desktop or a server.

Expand Down
76 changes: 74 additions & 2 deletions crates/gateway/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,75 @@
# Gateway family
# The gateway's API principle

This directory holds the gateway family's private crates, which together build the gateway, the separate server process that clients reach for models and the services around them. Code outside the family reaches the gateway only through the shared vocabulary and discovery crates one level up.
The gateway serves an API that other programs already know how to call. Each route follows the industry standard it is modeled on, where one exists, so a program written against that standard works against the gateway, and a client written against the gateway works against any server that implements the standard.

## The rule

Standards first. The chat, embeddings, models, speech, and transcription routes follow OpenAI's API: `POST /v1/chat/completions`, `POST /v1/embeddings`, `GET /v1/models`, `POST /v1/audio/speech`, and `POST /v1/audio/transcriptions`. The live stream on `/v1/realtime` follows OpenAI's Realtime transcription protocol: the same session object, the same audio buffer events, and the same `conversation.item.input_audio_transcription.delta` and `conversation.item.input_audio_transcription.completed` events.

`/v1/rerank` is the one route where OpenAI publishes no shape, so it follows the rerank shape that Jina and Cohere publish and that vLLM also serves. A client written for that shape works against the gateway.

Interoperability is checked in both directions. A standard client pointed at the gateway needs no special case, and a gateway client pointed at another server that speaks the standard needs none either.

## Extensions, and nothing else

Where a standard exists and the gateway needs something the standard lacks, the wire shape is the standard plus the smallest addition that makes the feature possible. Everything around the addition stays the standard's: route, methods, request fields, event names, error envelope.

Every addition carries three properties:

- It is named as an extension, with a name inside the standard's own namespace, so a reader of the standard's vocabulary can tell what it extends.
- It is documented beside the standard shape it extends, so both are read in one place.
- It is opt-in wherever the protocol offers a way to ask. A client that does not ask never receives the extra field or event.

A few routes have no standard behind them. `GET /v1/audio/voices` follows the convention the OpenAI-compatible servers converged on, and `/v1/tools/web_search` and the `/admin` routes are ours alone. Those count as extensions from the start, and the same three properties apply to them.

## Asking what a server offers

A client has to know whether a server offers an extension before it relies on one, and the asking has to be harmless to a server that has never heard of the gateway. An absent answer means the standard alone, never that something is broken.

The one nonstandard request has two candidate shapes. A dedicated capability endpoint is the preferred one, because it costs one request at connect time and every answer sits in one place. A response header on a call the client already makes costs no request, but it ties the answer to a call the client might never make, so it waits as a later convenience. Either way the plain server answers with a not-found, a no-content, or an empty result, which the client already tolerates. The endpoint is part of the intended arrangement the way the spec is. This is the rule the gateway follows when it adds one, and no such endpoint exists yet.

The probe only reports. The feature turns on through the negotiated include token the protocol already has, and only when the probe said the server has it. A server that answers the probe and then refuses the token has a fault on its side, and the client calls that standard only, never an error to show the user.

So a missing or negative answer produces an ordinary session, one that appends deltas and completes an item, with no second attempt, no error banner, and no probe attached to each request. Nothing the client shows suggests that a step failed, because none did.

Guessing does not replace asking. A version string alone is not evidence, an extension request sent first with recovery after the error is not a probe, a 404 is not a hard failure, and no probe may change the shape of a standard response.

## The live hypothesis

The extension the gateway depends on most is the live hypothesis in realtime transcription.

OpenAI's transcription stream is append-only. The `delta` events add text to an item, and the `completed` event closes it. Nothing in that shape can retract a word already sent. The gateway revises earlier words as more audio arrives and as later passes listen again, so it needs a way to say that part of the transcript changed.

A replacement snapshot is the smallest addition that supports revision: the item's full current text, a revision number, the audio span the snapshot covers, and how that text divides into finalized, agreed, and tentative parts. The client rebuilds the take from the snapshot, then patches its document word by word against the text it showed before, so a revision costs a few small edits and leaves the rest of the document alone.

The snapshot is the extra. That division is information the standard path never needs, because a standard client only appends, and a fallback ignores it.

A session asks for the snapshot through `session.include`, the field OpenAI already uses to negotiate optional output, by naming `item.input_audio_transcription.hypothesis`. A session that never names it receives plain deltas and a completed transcript.

## Nothing clever reaches the wire

The gateway runs several speech models and decides which result wins. That machinery stays behind the API. Which model ran, how the gateway reconciled them, and how long any of it took never appear in a response or an event. A caller sees a transcription endpoint that happens to be more accurate, or earlier, than one model alone would be. It never sees a model pick.

## The fallback is a requirement

A client pointed at a server without the extension must still transcribe, with the plainer behavior the standard gives. It falls back to the standard and keeps working with less.

A feature that only works against the gateway is a bug in the design. The test is whether the client still works when the extension is absent.

## What this rules out

- A parallel dialect of our own where a standard shape would do.
- Our own event or field names where a standard one exists.
- Removing an extension without a version step.
- An extension a caller cannot ignore.

## Checking a change

Before a route or a wire type changes, answer four questions:

1. Does the standard shape still hold?
2. Is the addition the smallest one that makes the feature possible, and is it opt-in?
3. Does the client still work without the addition?
4. Is the addition named as an extension in the spec?

The spec belongs with the gateway. The intended arrangement is a spec generated from the gateway's source rather than written by hand, with CI comparing the two so a route or a field cannot drift from its description. That keeps the principle in a check instead of in someone's memory.
1 change: 1 addition & 0 deletions crates/harness-gateway-client/src/wire/parse-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! reasoning side channel.

use super::*;
use runvil::model::CompletionErrorKind;

const EMPTY_REPLY_PHRASE: &str = "the model replied with no text and no tool calls";

Expand Down
29 changes: 6 additions & 23 deletions crates/harness-gateway-client/src/wire/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,12 @@
//! diagnostic naming it.

use runvil::metrics::{CallMetrics, LlamaTimings, Usage, VllmMetrics};
use runvil::model::{CompletionError, CompletionErrorKind, CompletionResult, ToolCall};
use runvil::model::{CompletionError, CompletionResult, ToolCall};
use serde::Deserialize;
use serde_json::Value;

use crate::failure::{decoded, malformed};

/// The specific added to an empty reply when reasoning was present but
/// ignored as answer text.
const REASONING_IGNORED: &str = "reasoning content was present but ignored";

/// A parsed assistant turn: outcome plus payload-free metadata.
#[derive(Debug, Clone, PartialEq)]
pub(super) struct NormalizedTurn {
Expand Down Expand Up @@ -94,23 +90,6 @@ fn turn_context(body: &Value) -> Result<TurnContext<'_>, CompletionError> {
})
}

/// The empty-reply failure for a turn with no product: the kind's phrase,
/// extended with [`REASONING_IGNORED`] when an ignored reasoning side
/// channel was present, holding the choice's `finish_reason` so the tool
/// loop can classify the empty turn.
fn empty_reply(reasoning_present: bool, finish_reason: Option<String>) -> CompletionError {
let kind = CompletionErrorKind::EmptyReply;
let error = if reasoning_present {
CompletionError::new(kind, format!("{}: {REASONING_IGNORED}", kind.phrase()))
} else {
CompletionError::new(kind, kind.phrase())
};
match finish_reason {
Some(reason) => error.with_finish_reason(reason),
None => error,
}
}

/// Turns a chat-completions response body into a [`NormalizedTurn`].
///
/// # Errors
Expand Down Expand Up @@ -157,7 +136,11 @@ pub(super) fn normalize(body: &Value) -> Result<NormalizedTurn, CompletionError>
});
}

Err(empty_reply(reasoning_content.is_some(), finish_reason))
let error = CompletionError::empty_reply(reasoning_content.is_some());
Err(match finish_reason {
Some(reason) => error.with_finish_reason(reason),
None => error,
})
}

/// Parses the OpenAI `message.tool_calls` array into [`ToolCall`]s.
Expand Down
4 changes: 2 additions & 2 deletions crates/harness-gateway-client/src/wire/request-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ fn tool_round(offered: &[Offered]) -> Round {
for IssuedEffect { id, effect, .. } in effects {
let answer = match effect {
Effect::Chat {
binding,
messages,
tools,
options,
..
} => {
let reply = if rounds.is_empty() {
Expand All @@ -98,7 +98,7 @@ fn tool_round(offered: &[Offered]) -> Round {
rounds.push(Round {
messages,
tools,
options,
options: binding.completion_options(),
});
EffectAnswer::Chat(Completion::from_result(reply, "m").map(Box::new))
}
Expand Down
2 changes: 1 addition & 1 deletion crates/harness-internal/runner/src/effect_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,11 @@ impl Driver {
binding,
messages,
tools,
options,
round,
..
} => {
let broker = Arc::clone(&self.performers.broker);
let options = binding.completion_options();
let round = async move {
let completion = broker.chat(binding, messages, tools, options, round).await;
EffectAnswer::Chat(completion)
Expand Down
22 changes: 22 additions & 0 deletions crates/plugin-std/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "plugin-std"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
# The Plugin contract: `PackInfo`, the Pack trait, the context a
# call is lent, and the tool, error, and service vocabulary.
runvil-plugin.workspace = true
# Each tool's definition is embedded JSON, read into a spec at construct.
serde.workspace = true
serde_json.workspace = true

[dev-dependencies]
# `testing::TestCall`, which lends the tools a context without a Harness.
runvil-plugin = { workspace = true, features = ["test-support"] }

[lints]
workspace = true
13 changes: 13 additions & 0 deletions crates/plugin-std/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Includes the file-size check's source instead of depending on
//! `build-ceiling`, because Plugin crates may name no `build-*` crate.

#[expect(
unreachable_pub,
reason = "the check's public items are private to this build script"
)]
#[path = "../build-ceiling/src/lib.rs"]
mod build_ceiling;

fn main() -> Result<(), build_ceiling::Violations> {
build_ceiling::check()
}
53 changes: 53 additions & 0 deletions crates/plugin-std/schemas/EDITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Definition edits

Each file in this directory is Cursor's definition of the tool named by its
`wire_name`, snapshot on 2026-10-27. Everything in a file is Cursor's text
except the edits below, each made because the line describes behavior this
Pack does not have.

## read.json

- `Reads a file from the local filesystem.` became `Reads a file from the
run's filesystem.`, because every read goes through the run's virtual
filesystem, not the real one.
- `Assume this tool is able to read all files on the machine.` became
`Assume this tool is able to read every file the run can see.`, for the
same reason.
- `If the User provides a path to a file` became `If the prompt provides a
path to a file`, because a Runvil run has a prompt rather than a chat
user.
- Removed: `Image Support`, `PDF Support`, and the paragraph explaining how
images and PDFs are returned. This build reads text only, so a model told
otherwise would send a PDF and get an error.

## write.json

- Cursor's `Write` description, which is about creating or overwriting a
file. Reworded to name `StrReplace` for targeted changes and to require a
read before overwriting, matching Cursor's own guidance. No behavior was
added; only the wording is ours.

## replace.json

- Cursor's `StrReplace` definition. The wording is ours; the parameters,
their types, and the `required` list are Cursor's.

## rm.json

- Verbatim. Cursor's `Delete` description and schema are kept exactly.

## glob.json

- The `target_directory` description's default, `Cursor workspace root`,
became `the run's filesystem root`.

## grep.json

- Removed the sentence pointing at `rg`, since the tool searches the run's
filesystem itself.
- Removed the sentence pointing at the `Shell` tool for piping, because this
Pack offers no `Shell`.
- The `path` parameter's default, `Cursor workspace root`, became `the
run's filesystem root`.
- Every parameter, type, description, enum, and the `required` list is
otherwise Cursor's.
18 changes: 18 additions & 0 deletions crates/plugin-std/schemas/glob.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"wire_name": "Glob",
"description": "Finds files by name pattern and returns their paths. Use this to locate files when you know part of a name or an extension but not the directory, and prefer it over listing directories one at a time.",
"parameters": {
"type": "object",
"properties": {
"glob_pattern": {
"type": "string",
"description": "The glob pattern to match files against"
},
"target_directory": {
"type": "string",
"description": "The absolute path to the directory to search in. If not provided, the run's filesystem root is used."
}
},
"required": ["glob_pattern"]
}
}
Loading
Loading