Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,35 @@ jobs:
- uses: dtolnay/rust-toolchain@1.86
- uses: Swatinem/rust-cache@v2
- name: Check
run: cargo check --all-features
# The optional `gasp` feature is excluded: yoagent-state currently
# requires a newer rustc (let-chains) than the crate's 1.86 MSRV.
run: cargo check --features openapi

# yoagent claims to be a tested GASP-conformant runtime — this job is the
# test: emit an agent repo with the gasp bridge (mock provider, no network)
# and run the protocol's conformance checker against it.
gasp-conformance:
name: GASP conformance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Emit agent repo via the gasp bridge
run: |
git config --global user.email "ci@yolog.dev"
git config --global user.name "yoagent-ci"
cargo run --example gasp_emit --features gasp -- /tmp/emitted-repo
- name: Checkout gasp (conformance checker)
uses: actions/checkout@v4
with:
repository: yologdev/gasp
ref: 08a60a2ee30d6bc065ffbadec34a7993dc109fdf # pin: checker changes must not break yoagent CI silently
path: gasp-checker
- name: Run conformance checker against a fresh clone (true restore)
run: |
# GASP restore IS `git clone` — checking the emitting worktree would
# pass on untracked scaffolding that a clone doesn't have.
git clone -q /tmp/emitted-repo /tmp/restored-repo
cd gasp-checker/conformance-check
cargo run -q -- /tmp/restored-repo
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to `yoagent` are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/), and the project
adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### Added

- **GASP bridge** (feature `gasp`) — `gasp::GaspRecorder` records agent runs
into a [GASP](https://github.com/yologdev/gasp) agent repo via
`yoagent-state`: append-only `state/events.jsonl` (goal/run/model/tool
events), one git commit per run (scaffolding committed at init so `git
clone` restores a complete agent), stale/interrupted runs closed safely,
events teed to your UI **before** recording (a recording failure never
blinds the UI; the error surfaces via the returned handle). Redaction hook
via `with_summarizer` — summaries of tool inputs/outputs are persisted to
a shareable repo. yoagent is now a **tested** GASP-conformant runtime: CI
emits a repo and runs the protocol's 7-check suite against a **fresh
clone** (the actual restore operation). New `gasp_emit` example and docs
page.

## 0.11.0

### Fixed (pre-release review of the items below)
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ Behind the `openapi` Cargo feature. `OpenApiToolAdapter` parses an OpenAPI 3.0 s

`McpClient` communicates via `McpTransport` trait (stdio or HTTP). `McpToolAdapter` wraps MCP tools to implement `AgentTool`, making them transparent to the agent loop. Added via `Agent::with_mcp_server_stdio()` / `with_mcp_server_http()`.

### GASP Bridge (`gasp.rs`, feature-gated)

Behind the `gasp` Cargo feature (dep: `yoagent-state`, the GASP reference runtime). `GaspRecorder` consumes the `AgentEvent` stream (via `recording_sender`, which also tees to a forward sender) and maps it onto `yoagent_state`'s `YoAgentStateSink`: AgentStart→run.started, assistant MessageEnd→model.called/finished pair, ToolExecutionStart/End→tool.called/finished, AgentEnd→run.finished + one git commit per run at stream close (scaffolding committed at init so clones restore). Stale open runs are closed as "interrupted" on open; a dropped sender finishes the run with the derived outcome; InputRejected → "rejected". Recording failures stop recording but the forward tee keeps flowing (error surfaces only via the returned handle — await it); events are forwarded BEFORE recording. `Ok(None)` when no AgentStart arrived. Zero loop changes. CI job `gasp-conformance` emits a repo via `examples/gasp_emit.rs` and runs the gasp conformance checker (7 checks) against it.

### Session Trees (`session.rs`)

`Session` stores history as an id/parent_id tree: `append` advances the head, `seek`/`seek_checkpoint` move it, appending after a seek forks a new branch (never overwrites). `path_messages()` feeds a branch into `Agent::with_messages`; `append_new(agent.messages())` is the post-run sync. JSONL persistence (`to_jsonl`/`from_jsonl`, head = last line). Freestanding — no loop changes; maps to GASP's `transcripts/` tier.
Expand Down
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,18 @@ rand = "0.10.0"
base64 = "0.22"
openapiv3 = { version = "2", optional = true }
serde_yaml_ng = { version = "0.10", optional = true }
yoagent-state = { version = "0.4.1", optional = true }

[features]
openapi = ["dep:openapiv3", "dep:serde_yaml_ng", "reqwest/query"]
gasp = ["dep:yoagent-state"]

[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }
tracing-subscriber = { version = "0.3", features = ["registry", "fmt"] }
tempfile = "3"
wiremock = "0.6"

[[example]]
name = "gasp_emit"
required-features = ["gasp"]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Everything is observable via events. Supports 7 API protocols covering 20+ LLM p
**Integrations**
- OpenAPI tool adapter — auto-generate tools from any OpenAPI 3.0 spec (`features = ["openapi"]`)
- MCP (Model Context Protocol) — connect to MCP tool servers via stdio or HTTP
- GASP (`features = ["gasp"]`) — record runs into a [GASP](https://github.com/yologdev/gasp) agent repo (append-only semantic event log; restore = clone + replay); yoagent is a **tested** GASP-conformant runtime — the protocol's 7-check conformance suite runs in CI

**Context Management**
- Context overflow detection across all major providers (Anthropic, OpenAI, Google, Bedrock, xAI, Groq, OpenRouter, llama.cpp, and more)
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [Sub-Agents](concepts/sub-agents.md)
- [State Persistence](concepts/persistence.md)
- [Session Trees](concepts/session-trees.md)
- [GASP: Your Agent Is a Git Repo](concepts/gasp.md)
- [Lifecycle Callbacks](concepts/callbacks.md)
- [Telemetry](concepts/telemetry.md)

Expand Down
78 changes: 78 additions & 0 deletions docs/concepts/gasp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# GASP: Your Agent Is a Git Repo

[GASP](https://github.com/yologdev/gasp) — the Git Agent State Protocol —
keeps an agent's durable self in a git repository: an **append-only semantic
event log** (`state/events.jsonl`) that folds into a typed
goal/run/model/tool graph, alongside identity, skills, and memory tiers.
Restore = `git clone` + replay. Clone your agent onto a new machine and it
remembers everything, with lineage.

yoagent bridges to GASP through the `gasp` feature (backed by
[`yoagent-state`](https://crates.io/crates/yoagent-state), the reference
runtime). The bridge is a consumer of the [`AgentEvent`] stream — **zero
agent-loop changes**:

```toml
yoagent = { version = "0.11", features = ["gasp"] }
```

> The `gasp` feature currently requires a newer Rust than the crate's 1.86
> MSRV (its `yoagent-state` dependency uses let-chains, stable since 1.88).


```rust
use yoagent::gasp::{GaspRecorder, GoalRef};

let recorder = GaspRecorder::init(
"./my-agent-repo", "my-agent", "worker-1",
GoalRef::New { title: "ship the feature".into() },
).await?;

let (tx, record_handle) = recorder.recording_sender("implement the parser", None);
agent.prompt_with_sender("implement the parser", tx).await;
let run_id = record_handle.await??; // events appended + committed
```

## What gets recorded

| Agent activity | GASP events |
|---|---|
| loop starts | `run.started` (the goal is stamped in the run commit's `Goal:` trailer) |
| each assistant turn | `model.called` / `model.finished` paired nodes |
| each tool execution | `tool.called` / `tool.finished` (with success flag) |
| loop ends | `run.finished` with the outcome (`completed` / `error` / `aborted` / ...) |

The semantic log stores bounded one-line summaries — the **task string
(verbatim)**, model ids, and the **first 200 characters of tool inputs, tool
outputs, and assistant text** — never full transcripts. If secrets can flow
through tool arguments or outputs, install a redacting summarizer
(`recorder.with_summarizer(...)`) before recording: the repo is designed to
be cloned and shared, and committed history is hard to scrub. Full
transcripts belong in GASP's cold `transcripts/` tier —
[`Session::to_jsonl`](session-trees.md) is a natural format for it.

Robustness: a crashed process leaves no open run — the recorder closes stale
runs as `interrupted` on open, and a dropped event stream finishes the run
with the outcome derived so far. One git commit per run keeps the history
append-only, and the init scaffolding (manifest, identity) is committed so a
fresh clone is a complete, conformant agent. If recording fails mid-run
(disk, lease, git), recording stops and the error surfaces via the returned
handle — **always await it** — while event forwarding to your UI continues
uninterrupted. GASP repos are single-writer: don't share one repo between
live workers, and record one run at a time.

## Tested conformance

yoagent's CI emits an agent repo with a mock provider and runs the GASP
conformance checker against it — all seven mechanical checks (envelope
round-trip, replay, vocabulary, append-only git history, causation integrity,
restore, domain↔ops consistency) must pass on every commit. Try it yourself:

```bash
cargo run --example gasp_emit --features gasp -- /tmp/my-agent
git clone https://github.com/yologdev/gasp && cd gasp/conformance-check
cargo run -q -- /tmp/my-agent
# conformant: all checks passed
```

[`AgentEvent`]: https://docs.rs/yoagent/latest/yoagent/enum.AgentEvent.html
9 changes: 5 additions & 4 deletions docs/concepts/session-trees.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ remains for single-branch persistence.

## GASP

This tree is the shape of the [GASP](https://github.com/yologdev/gasp)
`transcripts/` tier — the raw-conversation cold tier. The semantic event log
(goals/runs/evals living in a git repo) is a separate adapter layered on the
`AgentEvent` stream.
This tree is a natural format for the
[GASP](https://github.com/yologdev/gasp) `transcripts/` tier — the
raw-conversation cold tier (the spec leaves its format open). The semantic event log
lives in the [`gasp` feature](gasp.md): a recorder over the `AgentEvent`
stream that emits a conformance-checked agent repo.
84 changes: 84 additions & 0 deletions examples/gasp_emit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//! GASP emission example: run a (mock) agent and record it into a GASP agent
//! repo — the repo IS the agent's durable state.
//!
//! Run with: cargo run --example gasp_emit --features gasp -- [repo-path]
//!
//! The emitted repo passes the GASP conformance checker
//! (github.com/yologdev/gasp) — yoagent's CI verifies exactly that. Point it
//! at a path of your choice and inspect `state/events.jsonl` and `git log`
//! afterwards.

use yoagent::gasp::{GaspRecorder, GoalRef};
use yoagent::provider::mock::*;
use yoagent::provider::{MockProvider, ModelConfig};
use yoagent::*;

/// A tiny tool so the log shows tool-call pairs, not just model calls.
struct TouchTool;

#[async_trait::async_trait]
impl AgentTool for TouchTool {
fn name(&self) -> &str {
"touch"
}
fn label(&self) -> &str {
"Touch"
}
fn description(&self) -> &str {
"pretends to touch a file"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: ToolContext,
) -> Result<ToolResult, ToolError> {
Ok(ToolResult {
content: vec![Content::Text {
text: format!("touched {}", params["path"]),
}],
details: serde_json::Value::Null,
})
}
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let repo = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/yoagent-gasp-demo".into());

let recorder = GaspRecorder::init(
&repo,
"demo-agent",
"worker-1",
GoalRef::New {
title: "demonstrate GASP emission".into(),
},
)
.await?;

// Mock agent: one tool call, then a final answer — deterministic, no key.
let provider = MockProvider::new(vec![
MockResponse::ToolCalls(vec![MockToolCall {
provider_metadata: None,
name: "touch".into(),
arguments: serde_json::json!({"path": "hello.txt"}),
}]),
MockResponse::Text("Done: touched hello.txt".into()),
]);
let mut agent =
Agent::from_provider(provider, ModelConfig::mock()).with_tools(vec![Box::new(TouchTool)]);

let (tx, record_handle) = recorder.recording_sender("touch hello.txt", None);
agent.prompt_with_sender("touch hello.txt", tx).await;
let run_id = record_handle.await??.expect("run recorded");

println!("recorded run {run_id} into {repo}");
println!("inspect: cat {repo}/state/events.jsonl");
println!("verify: git clone https://github.com/yologdev/gasp");
println!(" cd gasp/conformance-check && cargo run -q -- {repo}");
Ok(())
}
Loading
Loading