feat(dev): the loop as a state machine the host can drive

protocol carries the events and the ten actions, and every action carries
the id its dispatch and completion are recorded under. encode and decode
are the only two functions that know the byte format, so ZAP takes over
from serde_json without moving a type or a symbol.

core is a Session with step, snapshot and restore, and no effects at all:
no filesystem, no process, no socket, no runtime. codex-core is not
separable as it stands — tokio, an HTTP client, a rollout store, a pty —
so the model-request and tool-result loop is driven here, and the module
doc names what stays coupled upstream. An observation is accepted only
while its id is outstanding, so a replayed result runs nothing twice.

ffi is the C ABI: seven symbols, generational handles so a stale one is
refused rather than resolving to the next session, catch_unwind on every
entry point so a panic poisons one session instead of aborting the
process, and dev.h written beside it. A C program exercises the archive.

protocol and core build for wasm32-wasip1; make wasm is the check.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-18 13:26:58 -07:00
co-authored by Hanzo Dev
parent 200eed1038
commit b7021cb588
14 changed files with 1671 additions and 3 deletions
Generated
+24
View File
@@ -5238,6 +5238,30 @@ version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "dev-core"
version = "0.7.0-dev.1"
dependencies = [
"dev-protocol",
"serde",
]
[[package]]
name = "dev-ffi"
version = "0.7.0-dev.1"
dependencies = [
"dev-core",
"dev-protocol",
]
[[package]]
name = "dev-protocol"
version = "0.7.0-dev.1"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "dialoguer"
version = "0.11.0"
+8 -1
View File
@@ -1,5 +1,12 @@
[workspace]
members = ["crates/hanzo-dev", "crates/hanzo-config", "crates/hanzo-tui"]
members = [
"crates/hanzo-dev",
"crates/hanzo-config",
"crates/hanzo-tui",
"crates/protocol",
"crates/core",
"crates/ffi",
]
exclude = ["upstream", "crates/hanzo-upstream"]
resolver = "2"
+8 -2
View File
@@ -1,7 +1,7 @@
TOOL := cargo run --quiet --manifest-path crates/hanzo-upstream/Cargo.toml --
CARGO := RUST_MIN_STACK=8388608 cargo
.PHONY: all prepare build release install test test-upstream bump v8 fmt clean reset help
.PHONY: all prepare build release install test test-upstream wasm bump v8 fmt clean reset help
.DEFAULT_GOAL := help
all: build
@@ -30,6 +30,12 @@ test: prepare
@$(CARGO) nextest run --locked --no-fail-fast $(ARGS)
@$(CARGO) nextest run --no-fail-fast --manifest-path crates/hanzo-upstream/Cargo.toml $(ARGS)
## wasm: build the reasoning half for wasm32-wasip1
# protocol and core carry no effects, so they run wherever a wasm host runs.
# ffi is native by definition and stays out of this.
wasm:
@$(CARGO) build --locked --target wasm32-wasip1 -p dev-protocol -p dev-core
## test-upstream: run the upstream suite against the pinned submodule
# voice-host wants GStreamer >= 1.28, which no current distribution ships, and
# nothing else in the workspace depends on it.
@@ -49,7 +55,7 @@ v8:
## fmt: format the crates Hanzo owns
fmt:
@$(CARGO) fmt -p hanzo-dev -p hanzo-config -p hanzo-tui
@$(CARGO) fmt -p hanzo-dev -p hanzo-config -p hanzo-tui -p dev-protocol -p dev-core -p dev-ffi
@$(CARGO) fmt --manifest-path crates/hanzo-upstream/Cargo.toml
## clean: discard build output
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "dev-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "dev_core"
path = "src/lib.rs"
[lints]
workspace = true
# A dependency here is a dependency of the reasoning half. It must not reach a
# filesystem, a process, a socket or an async runtime: the host owns effects.
[dependencies]
dev-protocol = { path = "../protocol", version = "0.7.0-dev.1" }
serde = { workspace = true, features = ["derive"] }
+258
View File
@@ -0,0 +1,258 @@
//! The loop, as a state machine — no clock, no disk, no socket.
//!
//! A [`Session`] holds the conversation and what the turn is waiting for. Feed
//! it an [`Event`] and it answers with the [`Action`]s the host should perform.
//! It never performs one itself: there is no `std::fs`, no `std::process`, no
//! `std::net` and no async runtime anywhere below this line, which is what lets
//! the same core run in cloud, in a terminal, and inside wasm.
//!
//! Two rules make the loop replayable:
//!
//! - Every action gets the next id from a counter that is part of the
//! snapshot, so a restored session continues the sequence rather than
//! repeating it.
//! - An observation is accepted only while its id is outstanding. A duplicate
//! delivery of the same result changes nothing and produces no action, so
//! at-least-once delivery of an event is not at-least-once execution of an
//! effect.
//!
//! # What upstream this does not reuse, and why
//!
//! `codex-core` is the closest existing loop, and it is not separable as it
//! stands: it depends on `tokio`, `reqwest` through `codex-http-client`, a
//! rollout store on disk, a pty, and a sandbox policy of its own — roughly
//! eighty crates, several of which are effects. Even `codex-protocol`, the
//! message vocabulary alone, pulls in `tokio` and the HTTP client. Taking
//! either into this crate would put a filesystem and a socket inside the
//! reasoning half and would not build for wasm. So the model-request and
//! tool-result loop is driven here.
//!
//! What stays coupled upstream, precisely: prompt assembly and the system
//! prompt (`codex-prompts`), context compaction (`codex-core::compact*`),
//! apply-patch parsing (`codex-apply-patch`), exec policy
//! (`codex-execpolicy`), and the provider-to-tool-call mapping that turns a
//! model's function call into a [`Call`]. Each of those is a pure function of
//! its inputs and can move into this crate one at a time; none of them is
//! reimplemented here in the meantime. Until then a host supplies the prompt
//! prelude, maps provider calls onto [`Call`], and keeps the context budget.
use std::collections::BTreeMap;
use std::collections::VecDeque;
use dev_protocol::Action;
use dev_protocol::Answer;
use dev_protocol::Ask;
use dev_protocol::Call;
use dev_protocol::Config;
use dev_protocol::Event;
use dev_protocol::Malformed;
use dev_protocol::Message;
use dev_protocol::Op;
use dev_protocol::Outcome;
use dev_protocol::Output;
use dev_protocol::Role;
use serde::Deserialize;
use serde::Serialize;
/// Which family of observation answers an outstanding effect.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
enum Wants {
Model,
Exec,
File,
Git,
Browse,
}
/// Conversation and turn state. Effects belong to the host.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Session {
config: Config,
messages: Vec<Message>,
/// The id the next action will carry.
next: u64,
/// Actions dispatched and not yet answered.
outstanding: BTreeMap<u64, Wants>,
/// Prompts that arrived while a turn was in flight.
queue: VecDeque<String>,
/// A turn is in flight.
busy: bool,
}
impl Session {
pub fn new(config: Config) -> Self {
let mut messages = Vec::new();
if let Some(prelude) = config.prelude.clone() {
messages.push(Message {
role: Role::Agent,
text: prelude,
});
}
Self {
config,
messages,
next: 1,
outstanding: BTreeMap::new(),
queue: VecDeque::new(),
busy: false,
}
}
/// The conversation so far.
pub fn messages(&self) -> &[Message] {
&self.messages
}
/// The id the next action will carry.
pub fn next_id(&self) -> u64 {
self.next
}
/// Advance the machine.
pub fn step(&mut self, event: Event) -> Vec<Action> {
match event {
Event::Turn(turn) => self.turn(turn.prompt),
Event::Model(answer) => self.answered(answer),
Event::Exec(output) => self.observed(Wants::Exec, output),
Event::File(output) => self.observed(Wants::File, output),
Event::Git(output) => self.observed(Wants::Git, output),
Event::Browse(output) => self.observed(Wants::Browse, output),
Event::Timer => self.tick(),
Event::Cancel => self.cancel(),
}
}
/// The core's own snapshot — one of the three facts a session is.
pub fn snapshot(&self) -> Vec<u8> {
dev_protocol::encode(self)
}
/// Rebuild a session from its snapshot.
pub fn restore(state: &[u8]) -> Result<Self, Malformed> {
dev_protocol::decode(state)
}
fn turn(&mut self, prompt: String) -> Vec<Action> {
if self.busy {
self.queue.push_back(prompt);
return Vec::new();
}
self.messages.push(Message {
role: Role::User,
text: prompt,
});
self.busy = true;
vec![self.ask()]
}
fn answered(&mut self, answer: Answer) -> Vec<Action> {
if self.outstanding.remove(&answer.id) != Some(Wants::Model) {
return Vec::new();
}
if let Some(text) = answer.reply.text.clone() {
self.messages.push(Message {
role: Role::Agent,
text,
});
}
if answer.reply.calls.is_empty() {
return self.finish(answer.reply.text);
}
answer
.reply
.calls
.into_iter()
.map(|call| self.dispatch(call))
.collect()
}
fn observed(&mut self, wants: Wants, output: Output) -> Vec<Action> {
if self.outstanding.remove(&output.id) != Some(wants) {
return Vec::new();
}
let mark = if output.failed { "failed" } else { "ok" };
self.messages.push(Message {
role: Role::Tool,
text: format!("{} {mark}: {}", output.id, output.text),
});
if self.outstanding.is_empty() {
vec![self.ask()]
} else {
Vec::new()
}
}
fn tick(&mut self) -> Vec<Action> {
if self.busy {
vec![self.action(Op::Save)]
} else {
Vec::new()
}
}
fn cancel(&mut self) -> Vec<Action> {
if !self.busy {
return Vec::new();
}
self.outstanding.clear();
self.queue.clear();
self.busy = false;
vec![
self.action(Op::Save),
self.action(Op::Done(Outcome::Cancelled)),
]
}
/// End the turn, or start the next queued prompt instead.
fn finish(&mut self, text: Option<String>) -> Vec<Action> {
let mut actions = Vec::new();
if let Some(text) = text {
actions.push(self.action(Op::Emit(text)));
}
actions.push(self.action(Op::Save));
match self.queue.pop_front() {
Some(prompt) => {
self.messages.push(Message {
role: Role::User,
text: prompt,
});
actions.push(self.ask());
}
None => {
self.busy = false;
actions.push(self.action(Op::Done(Outcome::Complete)));
}
}
actions
}
fn ask(&mut self) -> Action {
let op = Op::Model(Ask {
model: self.config.model.clone(),
messages: self.messages.clone(),
});
let action = self.action(op);
self.outstanding.insert(action.id, Wants::Model);
action
}
fn dispatch(&mut self, call: Call) -> Action {
let (op, wants) = match call {
Call::Read(read) => (Op::Read(read), Wants::File),
Call::Write(write) => (Op::Write(write), Wants::File),
Call::Patch(patch) => (Op::Patch(patch), Wants::File),
Call::Exec(exec) => (Op::Exec(exec), Wants::Exec),
Call::Git(git) => (Op::Git(git), Wants::Git),
Call::Browse(browse) => (Op::Browse(browse), Wants::Browse),
};
let action = self.action(op);
self.outstanding.insert(action.id, wants);
action
}
fn action(&mut self, op: Op) -> Action {
let id = self.next;
self.next += 1;
Action { id, op }
}
}
+208
View File
@@ -0,0 +1,208 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use dev_core::Session;
use dev_protocol::Action;
use dev_protocol::Answer;
use dev_protocol::Call;
use dev_protocol::Config;
use dev_protocol::Event;
use dev_protocol::Exec;
use dev_protocol::Op;
use dev_protocol::Outcome;
use dev_protocol::Output;
use dev_protocol::Reply;
use dev_protocol::Turn;
fn turn(prompt: &str) -> Event {
Event::Turn(Turn {
prompt: prompt.to_string(),
})
}
fn tools(calls: Vec<Call>, id: u64) -> Event {
Event::Model(Answer {
id,
reply: Reply { text: None, calls },
})
}
fn says(text: &str, id: u64) -> Event {
Event::Model(Answer {
id,
reply: Reply {
text: Some(text.to_string()),
calls: Vec::new(),
},
})
}
fn test() -> Call {
Call::Exec(Exec {
argv: vec!["cargo".to_string(), "test".to_string()],
cwd: None,
})
}
fn ops(actions: &[Action]) -> Vec<&Op> {
actions.iter().map(|a| &a.op).collect()
}
#[test]
fn a_turn_asks_a_model_then_runs_the_tool_then_asks_again() {
let mut session = Session::new(Config::default());
let asked = session.step(turn("fix the build"));
assert_eq!(asked.len(), 1);
assert_eq!(asked[0].id, 1);
assert!(matches!(asked[0].op, Op::Model(_)));
let dispatched = session.step(tools(vec![test()], 1));
assert_eq!(dispatched.len(), 1);
assert_eq!(dispatched[0].id, 2);
assert!(matches!(dispatched[0].op, Op::Exec(_)));
let again = session.step(Event::Exec(Output {
id: 2,
text: "ok".to_string(),
failed: false,
}));
assert_eq!(again.len(), 1);
assert_eq!(again[0].id, 3);
assert!(matches!(again[0].op, Op::Model(_)));
let done = session.step(says("built", 3));
assert_eq!(
ops(&done),
vec![
&Op::Emit("built".to_string()),
&Op::Save,
&Op::Done(Outcome::Complete),
]
);
assert_eq!(done[0].id, 4);
assert_eq!(done[2].id, 6);
}
#[test]
fn every_call_of_one_reply_gets_its_own_id() {
let mut session = Session::new(Config::default());
session.step(turn("run the suite twice"));
let dispatched = session.step(tools(vec![test(), test()], 1));
let ids: Vec<u64> = dispatched.iter().map(|a| a.id).collect();
assert_eq!(ids, vec![2, 3]);
// One answer is not enough: the model is asked again only when the last
// outstanding effect has been observed.
let quiet = session.step(Event::Exec(Output {
id: 2,
text: "ok".to_string(),
failed: false,
}));
assert!(quiet.is_empty());
let asked = session.step(Event::Exec(Output {
id: 3,
text: "ok".to_string(),
failed: false,
}));
assert_eq!(asked.len(), 1);
assert!(matches!(asked[0].op, Op::Model(_)));
}
#[test]
fn a_replayed_result_runs_nothing_twice() {
let mut session = Session::new(Config::default());
session.step(turn("fix the build"));
session.step(tools(vec![test()], 1));
let answered = Event::Exec(Output {
id: 2,
text: "ok".to_string(),
failed: false,
});
assert_eq!(session.step(answered.clone()).len(), 1);
assert!(session.step(answered).is_empty());
// The same id delivered by the wrong family is refused too.
assert!(
session
.step(Event::Git(Output {
id: 2,
text: "ok".to_string(),
failed: false,
}))
.is_empty()
);
}
#[test]
fn snapshot_and_restore_continue_at_the_same_id() {
let mut session = Session::new(Config::default());
session.step(turn("fix the build"));
let state = session.snapshot();
let mut restored = Session::restore(&state).expect("restore");
assert_eq!(restored.next_id(), session.next_id());
assert_eq!(restored.messages(), session.messages());
let here = session.step(tools(vec![test()], 1));
let there = restored.step(tools(vec![test()], 1));
assert_eq!(here, there);
assert_eq!(there[0].id, 2);
}
#[test]
fn a_prompt_mid_turn_waits_for_the_turn() {
let mut session = Session::new(Config::default());
session.step(turn("first"));
assert!(session.step(turn("second")).is_empty());
let actions = session.step(says("first done", 1));
assert_eq!(actions.len(), 3);
assert_eq!(actions[0].op, Op::Emit("first done".to_string()));
assert_eq!(actions[1].op, Op::Save);
assert!(matches!(actions[2].op, Op::Model(_)));
let closed = session.step(says("second done", actions[2].id));
assert!(matches!(closed.last().map(|a| &a.op), Some(Op::Done(_))));
}
#[test]
fn a_timer_checkpoints_only_inside_a_turn() {
let mut session = Session::new(Config::default());
assert!(session.step(Event::Timer).is_empty());
session.step(turn("fix the build"));
let saved = session.step(Event::Timer);
assert_eq!(ops(&saved), vec![&Op::Save]);
}
#[test]
fn cancel_ends_the_turn_and_drops_what_was_outstanding() {
let mut session = Session::new(Config::default());
session.step(turn("fix the build"));
session.step(tools(vec![test()], 1));
let stopped = session.step(Event::Cancel);
assert_eq!(
ops(&stopped),
vec![&Op::Save, &Op::Done(Outcome::Cancelled)]
);
// A result for the cancelled effect is no longer outstanding.
assert!(
session
.step(Event::Exec(Output {
id: 2,
text: "ok".to_string(),
failed: false,
}))
.is_empty()
);
assert!(session.step(Event::Cancel).is_empty());
}
#[test]
fn an_answer_to_an_id_that_was_never_asked_is_refused() {
let mut session = Session::new(Config::default());
session.step(turn("fix the build"));
assert!(session.step(says("hello", 99)).is_empty());
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "dev-ffi"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "dev"
path = "src/lib.rs"
crate-type = ["staticlib", "cdylib"]
[lints]
workspace = true
[dependencies]
dev-core = { path = "../core", version = "0.7.0-dev.1" }
dev-protocol = { path = "../protocol", version = "0.7.0-dev.1" }
+72
View File
@@ -0,0 +1,72 @@
/* The C ABI of the dev agent loop (HIP-1330).
*
* Written by hand and kept beside crates/ffi/src/lib.rs, which the C ABI test
* in crates/ffi/tests/abi.c compiles against the staticlib. There is no
* generator step to forget to run.
*
* Ownership:
* - Every pointer you pass in is copied before the call returns.
* - Every dev_buf handed back is a Rust allocation; release it with
* dev_free and never with free(3).
* - A session handle is opaque and generational: dev_drop retires it, and a
* retired handle answers DEV_HANDLE instead of naming a live session.
* - A call that panics answers DEV_PANIC and poisons that session; its later
* calls answer DEV_POISON until dev_drop. Other sessions are untouched.
*
* Payloads are dev-protocol messages (Config, Event, Action list, snapshot) in
* the encoding dev_abi() names.
*/
#ifndef HANZO_DEV_H
#define HANZO_DEV_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define DEV_OK 0 /* the call did what it was asked */
#define DEV_HANDLE (-1) /* the handle names no live session */
#define DEV_MALFORMED (-2) /* the payload is not a message of this protocol */
#define DEV_POISON (-3) /* a previous call on this session panicked */
#define DEV_PANIC (-4) /* this call panicked; the session is now poisoned */
#define DEV_NULL (-5) /* a pointer the caller must supply was null */
#define DEV_BUSY (-6) /* the session is inside another call */
#define DEV_FULL (-7) /* no slot is left for another session */
/* A byte string Rust owns until dev_free. */
typedef struct dev_buf {
uint8_t *ptr;
size_t len;
size_t cap;
} dev_buf;
/* Version of this ABI and of the payload encoding behind it. */
uint32_t dev_abi(void);
/* Start a session from an encoded Config, or from the default when len is 0.
* Writes the handle to out. */
int32_t dev_new(const uint8_t *cfg, size_t len, uint64_t *out);
/* Feed one encoded Event; writes the encoded action list to out. */
int32_t dev_step(uint64_t session, const uint8_t *event, size_t len, dev_buf *out);
/* Write the session's snapshot to out. */
int32_t dev_snapshot(uint64_t session, dev_buf *out);
/* Rebuild a session from a snapshot; writes its new handle to out. */
int32_t dev_restore(const uint8_t *state, size_t len, uint64_t *out);
/* End a session and retire its handle. */
void dev_drop(uint64_t session);
/* Release a dev_buf this library handed out. */
void dev_free(dev_buf buf);
#ifdef __cplusplus
}
#endif
#endif /* HANZO_DEV_H */
+463
View File
@@ -0,0 +1,463 @@
//! The C ABI the Go host drives the loop through.
//!
//! Seven symbols, an opaque `u64` per session, and a `Buf` for every byte
//! string that crosses back. The rules:
//!
//! - **Rust copies every input** before the call returns, so the host may free
//! or reuse its buffer immediately.
//! - **Rust owns every output** until the host calls [`dev_free`]. A `Buf` is
//! a Rust allocation; `free(3)` on it is wrong.
//! - **A handle is generational.** Dropping a session retires its handle, and
//! a stale handle is refused with [`DEV_HANDLE`] rather than resolving to
//! whatever took the slot next.
//! - **A panic does not cross the boundary.** Every entry point runs inside
//! `catch_unwind`; an escaped panic answers [`DEV_PANIC`] and poisons that
//! session, whose every later call answers [`DEV_POISON`] until it is
//! dropped. The process stays up and the other sessions are untouched.
mod slab;
use std::panic::AssertUnwindSafe;
use std::panic::catch_unwind;
use std::sync::Mutex;
use std::sync::MutexGuard;
use dev_core::Session;
use dev_protocol::Event;
use slab::Fault;
use slab::Slab;
/// The call did what it was asked.
pub const DEV_OK: i32 = 0;
/// The handle names no live session.
pub const DEV_HANDLE: i32 = -1;
/// The payload is not a message of this protocol.
pub const DEV_MALFORMED: i32 = -2;
/// A previous call on this session panicked.
pub const DEV_POISON: i32 = -3;
/// This call panicked; the session is now poisoned.
pub const DEV_PANIC: i32 = -4;
/// A pointer the caller must supply was null.
pub const DEV_NULL: i32 = -5;
/// The session is inside another call.
pub const DEV_BUSY: i32 = -6;
/// No slot is left for another session.
pub const DEV_FULL: i32 = -7;
/// A byte string Rust owns until [`dev_free`].
#[repr(C)]
pub struct Buf {
pub ptr: *mut u8,
pub len: usize,
pub cap: usize,
}
impl Buf {
/// A buffer that owns nothing — what a caller starts with.
pub const fn empty() -> Self {
Self {
ptr: std::ptr::null_mut(),
len: 0,
cap: 0,
}
}
}
static SESSIONS: Mutex<Slab<Session>> = Mutex::new(Slab::new());
/// Version of the ABI and of the payload encoding behind it.
#[unsafe(no_mangle)]
pub extern "C" fn dev_abi() -> u32 {
dev_protocol::ABI
}
/// Start a session from an encoded `Config`, or from the default when `len`
/// is zero. Writes the handle to `out`.
///
/// # Safety
/// `cfg` must be valid for `len` bytes unless `len` is zero, and `out` must
/// be writable. Both may be read only during the call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dev_new(cfg: *const u8, len: usize, out: *mut u64) -> i32 {
guard(|| {
if out.is_null() {
return DEV_NULL;
}
let config = if len == 0 {
dev_protocol::Config::default()
} else {
let Some(bytes) = (unsafe { copy(cfg, len) }) else {
return DEV_NULL;
};
match dev_protocol::decode(&bytes) {
Ok(config) => config,
Err(_) => return DEV_MALFORMED,
}
};
match sessions().insert(Session::new(config)) {
Ok(handle) => {
unsafe { out.write(handle) };
DEV_OK
}
Err(fault) => status(fault),
}
})
}
/// Feed one encoded `Event` and write the encoded `Vec<Action>` to `out`.
///
/// # Safety
/// `event` must be valid for `len` bytes unless `len` is zero, and `out` must
/// be writable. On [`DEV_OK`] `out` holds a buffer to release with
/// [`dev_free`].
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dev_step(
session: u64,
event: *const u8,
len: usize,
out: *mut Buf,
) -> i32 {
guard(|| {
if out.is_null() {
return DEV_NULL;
}
let Some(bytes) = (unsafe { copy(event, len) }) else {
return DEV_NULL;
};
enter(session, |session| {
let event: Event = match dev_protocol::decode(&bytes) {
Ok(event) => event,
Err(_) => return DEV_MALFORMED,
};
let actions = session.step(event);
unsafe { out.write(lend(dev_protocol::encode(&actions))) };
DEV_OK
})
})
}
/// Write the session's snapshot to `out`.
///
/// # Safety
/// `out` must be writable. On [`DEV_OK`] it holds a buffer to release with
/// [`dev_free`].
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dev_snapshot(session: u64, out: *mut Buf) -> i32 {
guard(|| {
if out.is_null() {
return DEV_NULL;
}
enter(session, |session| {
unsafe { out.write(lend(session.snapshot())) };
DEV_OK
})
})
}
/// Rebuild a session from a snapshot and write its new handle to `out`.
///
/// # Safety
/// `state` must be valid for `len` bytes unless `len` is zero, and `out` must
/// be writable.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dev_restore(state: *const u8, len: usize, out: *mut u64) -> i32 {
guard(|| {
if out.is_null() {
return DEV_NULL;
}
let Some(bytes) = (unsafe { copy(state, len) }) else {
return DEV_NULL;
};
let session = match Session::restore(&bytes) {
Ok(session) => session,
Err(_) => return DEV_MALFORMED,
};
match sessions().insert(session) {
Ok(handle) => {
unsafe { out.write(handle) };
DEV_OK
}
Err(fault) => status(fault),
}
})
}
/// End a session and retire its handle.
#[unsafe(no_mangle)]
pub extern "C" fn dev_drop(session: u64) {
guard(|| {
let _ = sessions().remove(session);
DEV_OK
});
}
/// Release a `Buf` this library handed out.
///
/// # Safety
/// `buf` must be one this library returned and not yet released.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dev_free(buf: Buf) {
guard(|| {
if !buf.ptr.is_null() {
drop(unsafe { Vec::from_raw_parts(buf.ptr, buf.len, buf.cap) });
}
DEV_OK
});
}
/// Run a call with the session lifted out of the slab, so no lock is held
/// while it runs and a panic leaves the slot poisoned rather than occupied.
fn enter(handle: u64, call: impl FnOnce(&mut Session) -> i32) -> i32 {
let mut session = match sessions().take(handle) {
Ok(session) => session,
Err(fault) => return status(fault),
};
match catch_unwind(AssertUnwindSafe(|| call(&mut session))) {
Ok(status) => {
sessions().put(handle, session);
status
}
Err(_) => {
drop(session);
sessions().poison(handle);
DEV_PANIC
}
}
}
/// Keep a panic inside Rust. An entry point that has no session to poison
/// still answers rather than unwinding into C.
fn guard(call: impl FnOnce() -> i32) -> i32 {
match catch_unwind(AssertUnwindSafe(call)) {
Ok(status) => status,
Err(_) => DEV_PANIC,
}
}
fn sessions() -> MutexGuard<'static, Slab<Session>> {
// The lock is held only for slab bookkeeping; no session code runs under
// it, so the only way it is poisoned is a panic in the slab itself, and
// the slab is still consistent in that case.
match SESSIONS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn status(fault: Fault) -> i32 {
match fault {
Fault::Stale => DEV_HANDLE,
Fault::Full => DEV_FULL,
Fault::Poisoned => DEV_POISON,
Fault::Busy => DEV_BUSY,
}
}
/// Copy the caller's bytes before anything else touches them.
///
/// # Safety
/// `ptr` must be valid for `len` bytes, or `len` must be zero.
unsafe fn copy(ptr: *const u8, len: usize) -> Option<Vec<u8>> {
if len == 0 {
return Some(Vec::new());
}
if ptr.is_null() {
return None;
}
Some(unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec())
}
/// Hand a Rust allocation to C. It comes back through [`dev_free`].
fn lend(mut bytes: Vec<u8>) -> Buf {
let buf = Buf {
ptr: bytes.as_mut_ptr(),
len: bytes.len(),
cap: bytes.capacity(),
};
std::mem::forget(bytes);
buf
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use dev_protocol::Action;
use dev_protocol::Config;
use dev_protocol::Op;
use dev_protocol::Turn;
fn start() -> u64 {
let mut handle = 0u64;
assert_eq!(
unsafe { dev_new(std::ptr::null(), 0, &raw mut handle) },
DEV_OK
);
assert_ne!(handle, 0);
handle
}
fn step(handle: u64, event: &Event) -> Result<Vec<Action>, i32> {
let bytes = dev_protocol::encode(event);
let mut out = Buf::empty();
let status = unsafe { dev_step(handle, bytes.as_ptr(), bytes.len(), &raw mut out) };
if status != DEV_OK {
return Err(status);
}
let actions = dev_protocol::decode(unsafe {
std::slice::from_raw_parts(out.ptr.cast_const(), out.len)
})
.expect("decode actions");
unsafe { dev_free(out) };
Ok(actions)
}
fn turn(prompt: &str) -> Event {
Event::Turn(Turn {
prompt: prompt.to_string(),
})
}
#[test]
fn the_abi_is_the_protocol_version() {
assert_eq!(dev_abi(), dev_protocol::ABI);
}
#[test]
fn a_step_round_trips_through_the_boundary() {
let handle = start();
let actions = step(handle, &turn("fix the build")).expect("step");
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].id, 1);
assert!(matches!(actions[0].op, Op::Model(_)));
dev_drop(handle);
}
#[test]
fn a_config_is_decoded_and_garbage_is_refused() {
let cfg = dev_protocol::encode(&Config {
model: Some("zen5.8".to_string()),
prelude: Some("you are a coding agent".to_string()),
});
let mut handle = 0u64;
assert_eq!(
unsafe { dev_new(cfg.as_ptr(), cfg.len(), &raw mut handle) },
DEV_OK
);
let actions = step(handle, &turn("fix the build")).expect("step");
match &actions[0].op {
Op::Model(ask) => {
assert_eq!(ask.model.as_deref(), Some("zen5.8"));
assert_eq!(ask.messages.len(), 2);
}
op => panic!("asked for {op:?}"),
}
dev_drop(handle);
let junk = b"not a config";
assert_eq!(
unsafe { dev_new(junk.as_ptr(), junk.len(), &raw mut handle) },
DEV_MALFORMED
);
}
#[test]
fn a_stale_handle_is_refused() {
let handle = start();
dev_drop(handle);
assert_eq!(step(handle, &turn("fix the build")), Err(DEV_HANDLE));
let mut out = Buf::empty();
assert_eq!(
unsafe { dev_snapshot(handle, &raw mut out) },
DEV_HANDLE,
"a snapshot of a dropped session"
);
// A handle from a different slab generation is refused as well.
let next = start();
assert_ne!(next, handle);
assert_eq!(step(handle, &turn("fix the build")), Err(DEV_HANDLE));
dev_drop(next);
}
#[test]
fn a_panic_poisons_the_session_and_leaves_the_process_up() {
let handle = start();
let quiet = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let status = enter(handle, |_| panic!("the loop gave up"));
std::panic::set_hook(quiet);
assert_eq!(status, DEV_PANIC);
assert_eq!(step(handle, &turn("fix the build")), Err(DEV_POISON));
let mut out = Buf::empty();
assert_eq!(unsafe { dev_snapshot(handle, &raw mut out) }, DEV_POISON);
// Another session is unharmed, and the poisoned handle frees cleanly.
let other = start();
assert!(step(other, &turn("fix the build")).is_ok());
dev_drop(other);
dev_drop(handle);
assert_eq!(step(handle, &turn("fix the build")), Err(DEV_HANDLE));
}
#[test]
fn a_malformed_event_is_refused_without_poisoning() {
let handle = start();
let junk = b"not an event";
let mut out = Buf::empty();
assert_eq!(
unsafe { dev_step(handle, junk.as_ptr(), junk.len(), &raw mut out) },
DEV_MALFORMED
);
assert!(step(handle, &turn("fix the build")).is_ok());
dev_drop(handle);
}
#[test]
fn a_null_out_pointer_is_refused() {
let handle = start();
let bytes = dev_protocol::encode(&turn("fix the build"));
assert_eq!(
unsafe { dev_step(handle, bytes.as_ptr(), bytes.len(), std::ptr::null_mut()) },
DEV_NULL
);
let mut out = Buf::empty();
assert_eq!(
unsafe { dev_step(handle, std::ptr::null(), 12, &raw mut out) },
DEV_NULL
);
dev_drop(handle);
}
#[test]
fn a_restored_session_continues_the_sequence() {
let handle = start();
let first = step(handle, &turn("fix the build")).expect("step");
let mut state = Buf::empty();
assert_eq!(unsafe { dev_snapshot(handle, &raw mut state) }, DEV_OK);
let mut restored = 0u64;
assert_eq!(
unsafe { dev_restore(state.ptr.cast_const(), state.len, &raw mut restored) },
DEV_OK
);
unsafe { dev_free(state) };
assert_ne!(restored, handle);
let answer = Event::Model(dev_protocol::Answer {
id: first[0].id,
reply: dev_protocol::Reply {
text: Some("built".to_string()),
calls: Vec::new(),
},
});
let here = step(handle, &answer).expect("step");
let there = step(restored, &answer).expect("step");
assert_eq!(here, there);
assert_eq!(there[0].id, first[0].id + 1);
dev_drop(handle);
dev_drop(restored);
}
}
+159
View File
@@ -0,0 +1,159 @@
//! A generational slab: a handle names a slot and the generation it was born
//! in, so a handle that outlives its session is refused instead of hitting
//! whatever took the slot next.
/// Why a handle did not resolve.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Fault {
/// No such slot, or the slot has been reused since.
Stale,
/// Every slot index is spoken for.
Full,
/// A call on this session panicked; the session is gone.
Poisoned,
/// The session is inside another call on another thread.
Busy,
}
struct Slot<T> {
generation: u32,
value: Option<T>,
poisoned: bool,
}
pub struct Slab<T> {
slots: Vec<Slot<T>>,
free: Vec<u32>,
}
impl<T> Slab<T> {
pub const fn new() -> Self {
Self {
slots: Vec::new(),
free: Vec::new(),
}
}
/// Store a value and return its handle. A handle is never zero.
pub fn insert(&mut self, value: T) -> Result<u64, Fault> {
match self.free.pop() {
Some(index) => {
let slot = match self.slots.get_mut(index as usize) {
Some(slot) => slot,
None => return Err(Fault::Stale),
};
slot.value = Some(value);
slot.poisoned = false;
Ok(handle(index, slot.generation))
}
None => {
let Ok(index) = u32::try_from(self.slots.len()) else {
return Err(Fault::Full);
};
self.slots.push(Slot {
generation: 1,
value: Some(value),
poisoned: false,
});
Ok(handle(index, 1))
}
}
}
/// Lift the value out for the duration of a call, so no lock is held
/// while the session runs.
pub fn take(&mut self, handle: u64) -> Result<T, Fault> {
let slot = self.slot(handle)?;
if slot.poisoned {
return Err(Fault::Poisoned);
}
slot.value.take().ok_or(Fault::Busy)
}
/// Put the value back after the call returned.
pub fn put(&mut self, handle: u64, value: T) {
if let Ok(slot) = self.slot(handle) {
slot.value = Some(value);
}
}
/// Mark the slot as poisoned: the value is gone and the handle answers
/// [`Fault::Poisoned`] until it is dropped.
pub fn poison(&mut self, handle: u64) {
if let Ok(slot) = self.slot(handle) {
slot.value = None;
slot.poisoned = true;
}
}
/// Free the slot and retire every handle to it.
pub fn remove(&mut self, handle: u64) -> Result<(), Fault> {
let (index, _) = parts(handle);
let slot = self.slot(handle)?;
slot.value = None;
slot.poisoned = false;
slot.generation = slot.generation.wrapping_add(1);
if slot.generation == 0 {
// Generation zero would make a handle collide with the reserved
// zero handle; skip it.
slot.generation = 1;
}
self.free.push(index);
Ok(())
}
fn slot(&mut self, handle: u64) -> Result<&mut Slot<T>, Fault> {
let (index, generation) = parts(handle);
let slot = self.slots.get_mut(index as usize).ok_or(Fault::Stale)?;
if slot.generation != generation {
return Err(Fault::Stale);
}
Ok(slot)
}
}
fn handle(index: u32, generation: u32) -> u64 {
(u64::from(generation) << 32) | u64::from(index)
}
fn parts(handle: u64) -> (u32, u32) {
let index = (handle & 0xffff_ffff) as u32;
let generation = (handle >> 32) as u32;
(index, generation)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn a_dropped_handle_goes_stale_even_when_the_slot_comes_back() {
let mut slab: Slab<u32> = Slab::new();
let first = slab.insert(7).expect("insert");
slab.remove(first).expect("remove");
let second = slab.insert(9).expect("insert");
assert_ne!(first, second);
assert_eq!(slab.take(first), Err(Fault::Stale));
assert_eq!(slab.take(second), Ok(9));
}
#[test]
fn a_handle_is_never_zero_and_zero_is_stale() {
let mut slab: Slab<u32> = Slab::new();
assert_ne!(slab.insert(1).expect("insert"), 0);
assert_eq!(slab.take(0), Err(Fault::Stale));
}
#[test]
fn a_taken_slot_is_busy_and_a_poisoned_one_stays_poisoned() {
let mut slab: Slab<u32> = Slab::new();
let handle = slab.insert(1).expect("insert");
assert_eq!(slab.take(handle), Ok(1));
assert_eq!(slab.take(handle), Err(Fault::Busy));
slab.poison(handle);
assert_eq!(slab.take(handle), Err(Fault::Poisoned));
slab.remove(handle).expect("remove");
assert_eq!(slab.take(handle), Err(Fault::Stale));
}
}
+101
View File
@@ -0,0 +1,101 @@
/* The ABI, exercised the way the Go host will exercise it: a session, a turn,
* a snapshot, a restore, and a handle that has been dropped.
*
* The event and the expected substrings below are in the protocol's current
* encoding (dev-protocol `encode`). When that encoding changes, this fixture
* changes with it and no declaration in dev.h moves.
*/
#include "dev.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #cond); \
failures++; \
} \
} while (0)
static const char *TURN = "{\"Turn\":{\"prompt\":\"fix the build\"}}";
/* The buffer Rust lends is not terminated; copy it before reading it as text. */
static char *text(dev_buf buf) {
char *copy = malloc(buf.len + 1);
if (copy == NULL) {
return NULL;
}
memcpy(copy, buf.ptr, buf.len);
copy[buf.len] = '\0';
return copy;
}
int main(void) {
CHECK(dev_abi() == 1);
uint64_t session = 0;
CHECK(dev_new(NULL, 0, &session) == DEV_OK);
CHECK(session != 0);
dev_buf actions = {0};
CHECK(dev_step(session, (const uint8_t *)TURN, strlen(TURN), &actions) == DEV_OK);
CHECK(actions.ptr != NULL);
CHECK(actions.len > 0);
char *shown = text(actions);
CHECK(shown != NULL);
if (shown != NULL) {
/* One action, id 1, asking for a model. */
CHECK(strstr(shown, "\"id\":1") != NULL);
CHECK(strstr(shown, "Model") != NULL);
free(shown);
}
dev_free(actions);
/* A snapshot restores into a second session that continues the sequence. */
dev_buf state = {0};
CHECK(dev_snapshot(session, &state) == DEV_OK);
CHECK(state.len > 0);
uint64_t restored = 0;
CHECK(dev_restore(state.ptr, state.len, &restored) == DEV_OK);
CHECK(restored != 0);
CHECK(restored != session);
dev_free(state);
const char *answer =
"{\"Model\":{\"id\":1,\"reply\":{\"text\":\"built\",\"calls\":[]}}}";
dev_buf ended = {0};
CHECK(dev_step(restored, (const uint8_t *)answer, strlen(answer), &ended) == DEV_OK);
shown = text(ended);
CHECK(shown != NULL);
if (shown != NULL) {
CHECK(strstr(shown, "\"id\":2") != NULL);
CHECK(strstr(shown, "Done") != NULL);
free(shown);
}
dev_free(ended);
dev_drop(restored);
/* Garbage is refused, and the session survives it. */
dev_buf refused = {0};
CHECK(dev_step(session, (const uint8_t *)"garbage", 7, &refused) == DEV_MALFORMED);
/* A dropped handle names nothing. */
dev_drop(session);
CHECK(dev_step(session, (const uint8_t *)TURN, strlen(TURN), &refused) == DEV_HANDLE);
CHECK(dev_snapshot(session, &refused) == DEV_HANDLE);
CHECK(dev_new((const uint8_t *)"garbage", 7, &session) == DEV_MALFORMED);
CHECK(dev_step(0, (const uint8_t *)TURN, strlen(TURN), &refused) == DEV_HANDLE);
CHECK(dev_step(session, (const uint8_t *)TURN, strlen(TURN), NULL) == DEV_NULL);
if (failures != 0) {
fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
printf("ok\n");
return 0;
}
+72
View File
@@ -0,0 +1,72 @@
//! Build the staticlib, link a C program against it, run it.
//!
//! `cargo test` builds a test harness, never the staticlib, so this test asks
//! cargo for the archive it is about to link — and asks rustc which system
//! libraries that archive needs, rather than keeping a per-platform list here.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
/// Where the staticlib and the C program are built: a target directory of
/// its own, so this build never waits on the lock the test run already holds.
fn shed() -> PathBuf {
root().join("target/abi")
}
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("workspace root")
}
fn run(command: &mut Command) -> (String, String) {
let what = format!("{command:?}");
let output = command.output().unwrap_or_else(|e| panic!("{what}: {e}"));
let out = String::from_utf8_lossy(&output.stdout).into_owned();
let err = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(output.status.success(), "{what} failed\n{out}\n{err}");
(out, err)
}
#[test]
fn a_c_program_drives_the_loop_through_the_staticlib() {
let shed = shed();
let (_, notes) = run(Command::new(env!("CARGO"))
.current_dir(root())
.args(["rustc", "-p", "dev-ffi", "--lib"])
.arg("--target-dir")
.arg(&shed)
.args(["--", "--print", "native-static-libs"]));
let archive = shed.join("debug/libdev.a");
assert!(archive.is_file(), "no staticlib at {}", archive.display());
let libs: Vec<String> = notes
.lines()
.find_map(|line| line.split_once("native-static-libs:"))
.map(|(_, libs)| libs.split_whitespace().map(str::to_string).collect())
.expect("rustc did not print the libraries the archive needs");
let program = shed.join("abi");
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let cc = std::env::var("CC").unwrap_or_else(|_| "cc".to_string());
run(Command::new(cc)
.arg("-std=c11")
.arg("-Wall")
.arg("-Wextra")
.arg("-Werror")
.arg("-I")
.arg(manifest.join("include"))
.arg(manifest.join("tests/abi.c"))
.arg(&archive)
.args(&libs)
.arg("-o")
.arg(&program));
let (out, err) = run(&mut Command::new(&program));
assert_eq!(out.trim(), "ok", "{err}");
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "dev-protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "dev_protocol"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+247
View File
@@ -0,0 +1,247 @@
//! Events in, actions out — the only language the loop and its host share.
//!
//! HIP-1330 splits a coding agent at the seam between reasoning and effect.
//! This crate is the alphabet of that seam: the host observes the world and
//! sends an [`Event`]; the core reasons and answers with [`Action`]s. Every
//! action carries an `id`, so the host's ledger can record dispatch and
//! completion under it and replay a result instead of repeating an effect.
//!
//! # Encoding
//!
//! [`encode`] and [`decode`] are the only two places that know the byte
//! format, so the format can change without moving a type or a C symbol.
//! Today it is `serde_json`. It becomes ZAP (HIP-0114) when the ZAP schema
//! compiler is a dependency this repo can build with: the Rust runtime crate
//! `zap` exists but its generated code needs the schema compiler binary, which
//! is not a build input here yet. Nothing below simulates ZAP framing in the
//! meantime.
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
/// Version of the byte format and of the C ABI that carries it.
pub const ABI: u32 = 1;
/// A decode failure, with the reason the bytes were refused.
#[derive(Debug)]
pub struct Malformed(String);
impl core::fmt::Display for Malformed {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "malformed payload: {}", self.0)
}
}
impl std::error::Error for Malformed {}
/// Encode a payload for the seam.
pub fn encode<T: Serialize>(value: &T) -> Vec<u8> {
// serde_json only fails on a serializer that refuses a value (a map with
// non-string keys, a float that is not finite). No protocol type has one.
match serde_json::to_vec(value) {
Ok(bytes) => bytes,
Err(e) => panic!("protocol type is not encodable: {e}"),
}
}
/// Decode a payload from the seam.
pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, Malformed> {
serde_json::from_slice(bytes).map_err(|e| Malformed(e.to_string()))
}
/// What the host tells the core.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// A person asks for work.
Turn(Turn),
/// A model answered the request dispatched under `id`.
Model(Answer),
/// A command dispatched under `id` exited.
Exec(Output),
/// A read, write or patch dispatched under `id` finished.
File(Output),
/// A git command dispatched under `id` exited.
Git(Output),
/// A browser request dispatched under `id` returned.
Browse(Output),
/// Time passed; a long turn should checkpoint.
Timer,
/// Stop this turn.
Cancel,
}
/// What the core asks the host to do.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Action {
pub id: u64,
pub op: Op,
}
/// The ten things a core can ask for.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Op {
Model(Ask),
Read(Read),
Write(Write),
Patch(Patch),
Exec(Exec),
Git(Git),
Browse(Browse),
/// Show this text to whoever is watching.
Emit(String),
/// Persist the core's snapshot at this point in the sequence.
Save,
/// The turn is over.
Done(Outcome),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Turn {
pub prompt: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ask {
pub model: Option<String>,
pub messages: Vec<Message>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Read {
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Write {
pub path: String,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Patch {
pub diff: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Exec {
pub argv: Vec<String>,
pub cwd: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Git {
pub argv: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Browse {
pub url: String,
pub body: Option<String>,
}
/// Why a turn ended.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Outcome {
Complete,
Cancelled,
}
/// A model's answer to an [`Op::Model`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Answer {
pub id: u64,
pub reply: Reply,
}
/// Text, tool calls, or both.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Reply {
pub text: Option<String>,
pub calls: Vec<Call>,
}
/// A tool call, already typed. The host's model layer maps a provider's
/// function call onto one of these; the core never dispatches on a string.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Call {
Read(Read),
Write(Write),
Patch(Patch),
Exec(Exec),
Git(Git),
Browse(Browse),
}
/// The result of an effect, naming the action it answers.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Output {
pub id: u64,
pub text: String,
pub failed: bool,
}
/// Who said something.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum Role {
User,
Agent,
Tool,
}
/// One entry of the conversation the core carries.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub text: String,
}
/// What a session is born with.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Config {
/// Model the core names in every [`Op::Model`]; the host routes it.
pub model: Option<String>,
/// First message of the conversation, if the host wants one.
pub prelude: Option<String>,
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn event_round_trips() {
let event = Event::Turn(Turn {
prompt: "fix the build".to_string(),
});
let back: Event = match decode(&encode(&event)) {
Ok(e) => e,
Err(e) => panic!("{e}"),
};
assert_eq!(event, back);
}
#[test]
fn action_round_trips_with_its_id() {
let action = Action {
id: 7821,
op: Op::Exec(Exec {
argv: vec!["cargo".to_string(), "test".to_string()],
cwd: None,
}),
};
let back: Action = match decode(&encode(&action)) {
Ok(a) => a,
Err(e) => panic!("{e}"),
};
assert_eq!(back.id, 7821);
assert_eq!(action, back);
}
#[test]
fn garbage_is_refused_with_a_reason() {
let err = decode::<Event>(b"not a payload").expect_err("garbage decoded");
assert!(err.to_string().starts_with("malformed payload:"));
}
}