Skip to content
Merged
2 changes: 2 additions & 0 deletions .vibelight/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.md
*.html
67 changes: 55 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,69 @@

Runvil runs AI prompt pipelines defined in a single Markdown file. The Markdown is the program, and the model is the CPU.

![Gloves and sparks](images/banner-04.png)
You write the model's instructions as prose and the logic as Lua, side by side in one file. Runvil runs that file with deterministic control flow, sends every model request through one gateway that holds your keys, and records every run.

Workshop is the desktop app for writing prompts, running them against local or remote models, and reviewing every run it records. Installers for Windows, macOS, and Linux are on the [latest release](https://github.com/cppalliance/runvil/releases/tag/workshop-latest).
![Workshop with a folder open, a file in the editor, and an agent answering in the chat panel](images/workshop-window.jpg)

![Android heads](images/banner-02.png)
## Workshop

The gateway is the one process that holds model credentials. It routes each model request to a cloud provider or to a model running on the user's own machine, so no other process ever sees a vendor key.
Workshop is the desktop app. You open your folders in it, edit files, and work with agents in the chat panel beside the editor. Every agent is a Runvil prompt: the built-in chat agent is one, and any prompt you put in your agents folder shows up next to it. Agents can search and read the web, ask you questions, and use the MCP servers you configure, and Workshop records each run in a local log that only ever grows.

![Holographic code](images/banner-03.png)
Installers for Windows, macOS (Apple Silicon and Intel), and Linux (AppImage and .deb, x86_64 and ARM64) are on the [nightly release](https://github.com/cppalliance/runvil/releases/tag/nightly). Nightly is a prerelease built from the latest code, and it doesn't update itself, so install a newer nightly to upgrade.

A prompt combines frontmatter, Lua code, and prose sections. The Engine runs it as a deterministic state machine, and the Harness performs every model call, tool call, and file access the Engine asks for. Workshop is one Host, and any other program can be a Host by running prompts through the same Harness.
Every installer includes the gateway. Workshop connects to a gateway that's already running or starts its own, and the gateway stays in the system tray after you close the window, so your loaded models stay loaded.

![Robot internals](images/banner-05.png)
## What a prompt looks like

The [documentation](https://cppalliance.org/runvil/) covers the prompt language, Workshop, the gateway, and the library references.
This is [`prompts/greet.md`](prompts/greet.md), one of the sample prompts in this repository:

![Filing cabinets](images/banner-06.png)
````markdown
---
name: greet
description: Greet the named input using a Lua-computed value
runvil: 0
models:
writer: {}
---

Pull requests are welcome, and every change must pass the formatting, lint, and test checks that CI runs.
# Greet

![Creator](images/runvil-portrait.png)
```lua
models.default("writer")
```

Runvil is distributed under the [Boost Software License](LICENSE).
Computes a greeting from the input in Lua, substitutes it into the prose, and
has the model echo it.

## Main

```lua
var.greeting = "Hello, " .. args .. "!"
```

Repeat exactly, with no extra words: {{ var.greeting }}

```lua
return models.infer(prose)
```
````

The frontmatter declares one model role, `writer`, and the Lua under the title makes it the default. Given the input `Ada`, the first block in `## Main` builds `Hello, Ada!`, the placeholder puts it into the prose, and `models.infer(prose)` sends that prose to the model. The model's reply is the run's result. The paragraph under the title never reaches the model, because no Lua reads it, so it works as a comment.

## 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 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.

## Documentation

The documentation site, [runvil.org](https://runvil.org), has the [Language guide](https://gh.cppalliance.org/runvil/language/) for writing prompts, the [Gateway guide](https://gh.cppalliance.org/runvil/gateway/) for installing and configuring the gateway, and the API references for embedding the Engine and the Harness in your own program.

## Contributing

Pull requests are welcome, and every change must pass the formatting, lint, and test checks that CI runs. [AGENTS.md](AGENTS.md) explains the project's terms and conventions and how to run those checks locally.

## License

Runvil is distributed under the [Boost Software License 1.0](LICENSE).
2 changes: 1 addition & 1 deletion crates/gateway/app/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Gateway

The gateway is the server process that holds every model credential. It serves an OpenAI-compatible API, routes each request to a cloud provider or a local model, manages the model catalog and local model downloads, and, when built with them, serves web search, speech, and a browser settings page. It starts serving before any slow model work begins, and on desktop machines it runs from the system tray. The [gateway guide](https://cppalliance.org/runvil/gateway/) documents its configuration, profiles, and endpoints.
The gateway is the server process that holds every model credential. It serves an OpenAI-compatible API, routes each request to a cloud provider or a local model, manages the model catalog and local model downloads, and, when built with them, serves web search, speech, and a browser settings page. It starts serving before any slow model work begins, and on desktop machines it runs from the system tray. The [gateway guide](https://gh.cppalliance.org/runvil/gateway/) documents its configuration, profiles, and endpoints.
5 changes: 2 additions & 3 deletions crates/harness-internal/runner/src/display_chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ use std::error::Error;
///
/// A cause is skipped when its text already appears in the text rendered
/// so far. Some error variants copy their source's text into their own
/// message, and the Engine's `LuaRuntime` variant is one example.
/// Appending such a cause again would print its text twice. The check is
/// a plain substring test.
/// message. Appending such a cause again would print its text twice. The
/// check is a plain substring test.
#[must_use]
pub fn display_chain(error: &dyn Error) -> String {
let mut rendered = error.to_string();
Expand Down
5 changes: 2 additions & 3 deletions crates/runvil-internal/engine/src/error/sets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
//! the message [`Error::ToolSets`](super::Error::ToolSets) renders when
//! the sets break one.

use runvil_lua::OfferMode;
use runvil_types::packs::PackId;
use runvil_types::tools::ToolId;

use crate::execute::scope::FrozenSet;

/// A merge rule a section entry's frozen sets broke.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SetRule {
Expand Down Expand Up @@ -38,7 +37,7 @@ pub(crate) struct SetTool {
/// The tool's id.
pub(crate) id: ToolId,
/// The set the tool sits in.
pub(crate) set: FrozenSet,
pub(crate) set: OfferMode,
}

/// `packs` as a bracketed list, such as `[web, gh]`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! admission against the next, a task holds a slot at its owner and every
//! ancestor, a task wait gives those slots back and a timer-only wait keeps
//! them, a call chain gives them back through its nearest holding ancestor,
//! and a forgotten chain leaves admission.
//! a forgotten chain leaves admission, and a pass that fails leaves
//! admission as it found it.

use std::num::NonZeroUsize;

Expand Down Expand Up @@ -257,4 +258,60 @@ fn a_resumption_whose_slots_belong_to_no_admitted_task_is_an_internal_error() {
admission.plan(&tree.view()),
Err(Error::Internal { .. })
));
assert!(admission.resuming.contains(&queued));
}

#[test]
fn a_pass_that_fails_on_an_internal_error_leaves_admission_as_it_found_it() {
let mut tree = Tree::new();
let mut admission = Admission::default();
let root = tree.root(2);
let task = spawn(&mut admission, &mut tree, root);
let call = tree.call(root);
admission.enqueue_spawn(
call,
SpawnRecord {
input: None,
section: "not a task".to_owned(),
},
);
assert!(matches!(
admission.plan(&tree.view()),
Err(Error::Internal { .. })
));
assert!(admission.is_queued(task));
assert!(!admission.holding.contains(&task));
}

#[test]
fn a_pass_that_fails_after_admitting_a_resumption_leaves_its_slots_given_back() {
let mut tree = Tree::new();
let mut admission = Admission::default();
let root = tree.root(2);
let task = spawn(&mut admission, &mut tree, root);
assert_eq!(plan(&mut admission, &tree), [Admitted::Started(task)]);
let call = tree.call(task);
admission.park(call, &tree.view(), true);
assert!(admission.wake(call, &tree.view()));
let stray = tree.call(root);
admission.enqueue_spawn(
stray,
SpawnRecord {
input: None,
section: "not a task".to_owned(),
},
);
assert!(matches!(
admission.plan(&tree.view()),
Err(Error::Internal { .. })
));
assert_eq!(admission.released.get(&call), Some(&task));
assert!(admission.resuming.contains(&call));
assert!(!admission.holding.contains(&task));
admission.forget(stray);
assert_eq!(
plan(&mut admission, &tree),
[Admitted::Resumed(call)],
"the failed pass reached the stray spawn after admitting the resumption"
);
}
79 changes: 47 additions & 32 deletions crates/runvil-internal/engine/src/execute/scheduler/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,63 +157,60 @@ impl Admission {
///
/// # Errors
/// Returns [`Error::Internal`] when a resumed chain's slots belong to
/// no admitted task, or a queued spawn is not a task chain.
/// no admitted task, or a queued spawn is not a task chain. A failed
/// pass leaves admission as it found it: both queues, the holders, and
/// the given-back slots.
pub(super) fn plan(&mut self, tree: &ChainTree<'_>) -> Result<Vec<Admit>> {
let mut admitted = Vec::new();
if self.resuming.is_empty() && self.spawned.is_empty() {
return Ok(admitted);
}
let mut used = self.slots_in_use(tree);
let mut remaining = VecDeque::new();
for id in std::mem::take(&mut self.resuming) {
let holder = self.slot_holder(id);
let mut holding = self.holding.clone();
let mut released = self.released.clone();
let mut used = slots_in_use(&holding, tree);
let mut resuming = VecDeque::new();
for &id in &self.resuming {
let holder = slot_holder(&released, id);
if !can_admit(&used, tree, holder) {
remaining.push_back(id);
resuming.push_back(id);
continue;
}
if !matches!(tree.kind(holder), ChainKind::Task) || self.is_queued(holder) {
return Err(Error::internal(
"a resumed chain's slots belong to an admitted task",
));
}
self.holding.insert(holder);
self.released.remove(&id);
holding.insert(holder);
released.remove(&id);
count_slot(&mut used, tree, holder);
admitted.push(Admit::Resumed(id));
}
self.resuming = remaining;
let mut remaining = VecDeque::new();
for (id, record) in std::mem::take(&mut self.spawned) {
let mut starts = Vec::with_capacity(self.spawned.len());
for &(id, _) in &self.spawned {
if !can_admit(&used, tree, id) {
remaining.push_back((id, record));
starts.push(false);
continue;
}
if !matches!(tree.kind(id), ChainKind::Task) {
return Err(Error::internal("a queued chain backs a task"));
}
self.holding.insert(id);
holding.insert(id);
count_slot(&mut used, tree, id);
admitted.push(Admit::Started(id, record));
starts.push(true);
}
self.spawned = remaining;
Ok(admitted)
}

/// The chain whose slots `id`'s resumption takes back: the holding
/// ancestor a parked call chain gave back, or the resumed task itself.
fn slot_holder(&self, id: ChainIndex) -> ChainIndex {
self.released.get(&id).copied().unwrap_or(id)
}

/// The admission slots in use at each chain, by arena index, counted
/// from the chains holding theirs: a chain's count is its own
/// children's and, transitively, every descendant's.
fn slots_in_use(&self, tree: &ChainTree<'_>) -> Vec<usize> {
let mut used = vec![0; tree.chains.len()];
for holder in &self.holding {
count_slot(&mut used, tree, *holder);
let mut spawned = VecDeque::new();
for ((id, record), start) in std::mem::take(&mut self.spawned).into_iter().zip(starts) {
if start {
admitted.push(Admit::Started(id, record));
} else {
spawned.push_back((id, record));
}
}
used
self.resuming = resuming;
self.spawned = spawned;
self.holding = holding;
self.released = released;
Ok(admitted)
}

/// Parks `id` on a task wait. A wait that holds real tasks gives
Expand Down Expand Up @@ -289,6 +286,24 @@ impl Admission {
}
}

/// The chain whose slots `id`'s resumption takes back: the holding
/// ancestor a parked call chain gave back, as `released` records it, or
/// the resumed task itself.
fn slot_holder(released: &HashMap<ChainIndex, ChainIndex>, id: ChainIndex) -> ChainIndex {
released.get(&id).copied().unwrap_or(id)
}

/// The admission slots in use at each chain, by arena index, counted
/// from the task chains in `holding`: a chain's count is its own
/// children's and, transitively, every descendant's.
fn slots_in_use(holding: &HashSet<ChainIndex>, tree: &ChainTree<'_>) -> Vec<usize> {
let mut used = vec![0; tree.chains.len()];
for holder in holding {
count_slot(&mut used, tree, *holder);
}
used
}

/// Counts one slot the task chain `id` holds into `used`, at its owner
/// and every enclosing ancestor.
fn count_slot(used: &mut [usize], tree: &ChainTree<'_>, id: ChainIndex) {
Expand Down
5 changes: 1 addition & 4 deletions crates/runvil-internal/engine/src/execute/scheduler/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,7 @@ impl ChainOps<'_> {
/// issued the call (a tool's own failure then resumes as untrusted
/// failure text), else as a script call classified by the binding's
/// declared output kind. A script call that receives a listing marks
/// the chain's frame's innermost open adhoc call. The counts were
/// taken at dispatch, so the body is handed `None` for them.
/// the chain's frame's innermost open adhoc call.
fn accept_tool_call(
&mut self,
call: &ToolCallContinuation,
Expand All @@ -307,7 +306,6 @@ impl ChainOps<'_> {
&call.binding,
vendor,
result,
None,
nonce,
emitter,
section,
Expand All @@ -320,7 +318,6 @@ impl ChainOps<'_> {
&call.binding,
vendor,
result,
None,
nonce,
emitter,
section,
Expand Down
14 changes: 7 additions & 7 deletions crates/runvil-internal/engine/src/execute/scope-tests-render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ fn broken(error: Error) -> (SetRule, Vec<SetTool>) {

/// The tool `id` in `set`, as a failed merge names it.
#[track_caller]
fn site(id: &str, set: FrozenSet) -> SetTool {
fn site(id: &str, set: OfferMode) -> SetTool {
SetTool {
id: ToolId::parse(id).expect("a fixture tool id"),
set,
Expand Down Expand Up @@ -85,42 +85,42 @@ fn rule_one_refuses_a_rendered_tool_outside_what_its_pack_was_asked() {
(
"a model-only tool in a set its Pack was not offered into",
gh,
FrozenSet::Static,
OfferMode::Static,
"gh/new",
SetRule::UnofferedSet(pack("gh")),
),
(
"a model-only tool under another Pack's id",
web,
FrozenSet::Static,
OfferMode::Static,
"gh/new",
SetRule::ForeignTool(pack("web")),
),
(
"another Pack's catalog tool",
web,
FrozenSet::Dynamic,
OfferMode::Dynamic,
"gh/one",
SetRule::ForeignTool(pack("web")),
),
(
"a catalog tool the section never offered",
web,
FrozenSet::Static,
OfferMode::Static,
"web/other",
SetRule::UnofferedCatalogTool,
),
(
"a catalog tool the section offered only into the Dynamic Set",
web,
FrozenSet::Static,
OfferMode::Static,
"web/search",
SetRule::UnofferedCatalogTool,
),
(
"a catalog tool the section offered only into the Static Set",
web,
FrozenSet::Dynamic,
OfferMode::Dynamic,
"web/fetch",
SetRule::UnofferedCatalogTool,
),
Expand Down
Loading
Loading