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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ that makes them stale. This regression has happened twice (#33 and again
returns a real verdict with NO `CORA_*` exports in the environment —
verified across PRs #164/#166). The 2026-09-18 export requirement is
obsolete.
- **2026-10-08:** both `tole-core` and `tole-cli` are on crates.io at
**0.7.2** (released via the GitHub release `v0.7.2`; `publish.yml`
- **2026-10-09:** both `tole-core` and `tole-cli` are on crates.io at
**0.8.0** (released via the GitHub release `v0.8.0`; `publish.yml`
published core then cli; checked via the crates.io API). Registry
parity with the checkout is current as of this date; live missions still run the
freshly built `./target/release/tole` (see CONTRIBUTING →
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.1] — 2026-10-09

### Fixed
- The usage report no longer renders an absent per-step `messages` count as a measured `0`; it is `null` in the status JSON when unknown (found by the pre-tag scan).
- `read_file` rejects an empty `path` with a clear message instead of a confusing "is a directory" error.

### Added
- `tole status` and the serve status JSON now report token usage, cache-hit rate and a per-step request-size split (system+tools vs history, stored per step under `tole_wire` in the usage record); `fact/mission` gains `cached_tokens`. Instrumentation only: the wire, budgets and durable entry shapes are unchanged (#211, part 1 of 3).

### Changed
- `read_file` now returns at most 20,000 characters per call and accepts `offset`/`limit` (characters); the result carries `truncated`, `total_chars` and `next_offset` so large files are read in chunks instead of being re-sent whole on every later step (#211, part 3 of 3).

## [0.8.0] — 2026-10-09

### Added
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/tole-core", "crates/tole-cli"]

[workspace.package]
version = "0.8.0"
version = "0.8.1"
edition = "2021"
license = "MIT"
repository = "https://github.com/codecoradev/tole"
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ Durable Rust agent harness: a conversational agent with risk-tiered approval
gates, a write-once JSONL session log, and a register state machine — resumable
after crashes, replayable forever.

**Status:** v0.7.2 released; v0.8.0 is release-candidate on `develop` — it adds
the per-project config `.tole/config.toml` (trusted before it applies; see the
Configuration section) on top of v0.7.x (rescan-2 hardening, one tool-call
authorization gate; see the CHANGELOG for the behavior changes). Four faces on one durable core: the CLI (run / chat /
**Status:** v0.8.1 released — it adds token-usage reporting and a capped, resumable `read_file` (#211) on top of v0.8.0, which added the per-project config `.tole/config.toml`
(trusted before it applies; see the Configuration section) on top of v0.7.x
(rescan-2 hardening, one tool-call authorization gate; see the CHANGELOG for
the behavior changes). Four faces on one durable core: the CLI (run / chat /
resume / sessions / jobs / **mission**), `tole mcp` (tool server),
`tole acp` (editor agent), and `tole serve` (REST + multi-session
MCP-over-HTTP daemon). Identity (owner-approved): a chat-first
Expand Down Expand Up @@ -93,7 +93,7 @@ tole chat --memory uteke # …with cross-session memory
tole chat --resume <id> # pick the thread back up (also: --last)
tole resume <id> "next instruction" # continue a settled session
tole sessions # list durable sessions
tole status <id> # pc / seq / turns / token usage
tole status <id> # pc / seq / turns / token + cache usage / request-size split
```

Sessions live in `.tole/sessions/<id>.jsonl` (override with
Expand Down Expand Up @@ -219,7 +219,7 @@ hard error.

| Tool | Risk | Notes |
|------|------|-------|
| `read_file`, `write_file`, `edit_file` | RO / Write | jailed to `--workspace` (TOCTOU-safe, symlink-refusing) |
| `read_file`, `write_file`, `edit_file` | RO / Write | jailed to `--workspace` (TOCTOU-safe, symlink-refusing); `read_file` returns at most 20,000 chars per call (`offset`/`limit` in chars, `next_offset` to continue) |
| `delete_file` | Destructive | always prompts; never allowlistable, even with `--yes` |
| `git` | Write | `status` / `diff` / `add` / `commit` only — **push stays human** |
| `gh` | Write | read-only ops, argv-validated per op; `repo` defaults to the checkout's GitHub remote, optional per-call override (validated) |
Expand Down Expand Up @@ -278,7 +278,13 @@ cap; expiry cancels at a checkpoint — resumable, never dead).
Budget tiers (#201): conservative defaults (48 steps / 15 min / 200k
tokens) with headroom under `--trust internal` (96 / 30 / 500k) —
explicit flags always win; a durable cost report (turns, steps, tokens,
per-risk-tier tool calls) lands on the session and `tole status` renders it.
per-risk-tier tool calls, cached tokens) lands on the session and `tole status` renders it.
`tole status` (and `GET /sessions/{id}/status`, as `usage_report`) also shows
the usage derived from the ledger for any session: prompt / completion /
reasoning / cached tokens, the cache-hit rate, and the request size in
characters split into system+tools vs history at the first and last step
(#211; recorded per step under `tole_wire`; sessions recorded before it read
`n/a`). Reported only — `--max-tokens` accounting is unchanged.

## Approval gates

Expand Down
2 changes: 1 addition & 1 deletion crates/tole-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ mcp-http = ["mcp", "shell-tools", "dep:hyper", "dep:hyper-util", "dep:http-body-
replay = []

[dependencies]
tole-core = { version = "0.8.0", path = "../tole-core" }
tole-core = { version = "0.8.1", path = "../tole-core" }
tokio = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/tole-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ pub mod session_host;
pub mod session_tools;
pub mod tools;
pub mod trust;
pub mod usage_report;
5 changes: 5 additions & 0 deletions crates/tole-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2120,6 +2120,11 @@ fn status_command(sessions_dir: &Path, id: &str) -> Result<()> {
if let Some(mission) = storage.get_register("fact", "mission") {
println!("mission: {}", mission);
}
// Issue #211: usage report derived from the ledger (additive block,
// rendered after the pre-existing lines; older sessions read `n/a`).
for line in tole_cli::usage_report::usage_report(usage).render_lines() {
println!("{line}");
}
Ok(())
}

Expand Down
78 changes: 76 additions & 2 deletions crates/tole-cli/src/mission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ fn run_verify(cmd: &str, timeout: std::time::Duration) -> Result<(), String> {

/// Snapshot the durable usage ledger (provider steps so far).
fn steps_used(storage: &JsonlStorage) -> u64 {
storage.usages().len() as u64
// Wire-only rows (#211: request sizes without provider usage) are not
// provider steps — budget semantics are exactly as before.
tole_cli::usage_report::provider_steps(storage.usages())
}

/// Tokens (prompt + completion) recorded in the durable ledger.
Expand Down Expand Up @@ -406,6 +408,10 @@ pub fn run_mission(
"turns": turns,
"steps": steps_used(&storage),
"tokens_in_out": tokens_used(&storage),
// Issue #211: reported only — never counted against any budget.
// `null` when no step reported cached tokens (unknown, not 0).
"cached_tokens": tole_cli::usage_report::usage_report(storage.usages())
.to_json()["cached_tokens"],
"verify_failures": verify_failures,
"wall_seconds": wall_secs,
"budget": {"max_steps": cfg.max_steps, "max_minutes": cfg.max_minutes,
Expand Down Expand Up @@ -445,7 +451,75 @@ pub fn run_mission(

#[cfg(test)]
mod budget_tests {
use super::BudgetTier;
use super::{steps_used, tokens_used, BudgetTier};
use serde_json::{json, Value};
use tole_core::entry::Entry;
use tole_core::provider::{Provider, ProviderError, ProviderOutput};
use tole_core::storage::{JsonlStorage, Storage};
use tole_core::tool::ToolRegistry;
use tole_core::turn::run_turn;

/// Reports request sizes (#211) and, optionally, provider usage.
struct Wired {
usage: Option<Value>,
}
impl Provider for Wired {
fn complete(&mut self, _t: &[Entry]) -> Result<ProviderOutput, ProviderError> {
Ok(ProviderOutput::Final { text: "ok".into() })
}
fn last_usage(&self) -> Option<Value> {
self.usage.clone()
}
fn last_wire_stats(&self) -> Option<Value> {
Some(json!({"system_chars": 1, "tools_chars": 2, "history_chars": 3, "messages": 1}))
}
}

fn store(tag: &str) -> JsonlStorage {
let d = std::env::temp_dir().join(format!(
"tole-budget-{tag}-{}-{:x}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
JsonlStorage::create(&d, "b", None).unwrap()
}

/// #211 invariant: a gateway that reports NO usage leaves wire-only
/// ledger rows, which must not count as steps or tokens — the step
/// and token budgets behave exactly as before.
#[test]
fn wire_only_rows_do_not_move_step_or_token_budgets() {
let mut s = store("wire-only");
let reg = ToolRegistry::new();
let mut p = Wired { usage: None };
for t in ["a", "b", "c"] {
run_turn(&mut s, &mut p, &reg, t).unwrap();
}
assert_eq!(s.usages().len(), 3, "rows are stored (PR 2 needs them)");
assert_eq!(steps_used(&s), 0);
assert_eq!(tokens_used(&s), 0);
}

/// Mixed ledger: only rows with provider usage are steps.
#[test]
fn mixed_ledger_counts_only_provider_usage_steps() {
let mut s = store("mixed");
let reg = ToolRegistry::new();
run_turn(&mut s, &mut Wired { usage: None }, &reg, "a").unwrap();
let mut with = Wired {
usage: Some(json!({"prompt_tokens": 10, "completion_tokens": 4})),
};
run_turn(&mut s, &mut with, &reg, "b").unwrap();
run_turn(&mut s, &mut Wired { usage: None }, &reg, "c").unwrap();
run_turn(&mut s, &mut with, &reg, "d").unwrap();
assert_eq!(s.usages().len(), 4);
assert_eq!(steps_used(&s), 2);
assert_eq!(tokens_used(&s), 28);
}

/// Issue #201: explicit flags win per-field; the trusted tier only
/// fills where the operator gave nothing.
Expand Down
16 changes: 11 additions & 5 deletions crates/tole-cli/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,17 +544,22 @@ fn route(state: &State, method: &str, path: &str, body: &str) -> (u16, serde_jso
// blocking status on it would pin the endpoint (and
// the map) for the entire turn (CodeCora scan
// 2026-09-28). try_lock keeps this non-blocking.
let (busy, entries) = {
let (busy, entries, report) = {
let sessions = lock_sessions(&state.sessions);
let Some(st) = sessions.map.get(id) else {
return (404, json!({"error": "unknown session"}));
};
let busy = *st.busy.lock().expect("busy lock");
let entries = match st.storage.try_lock() {
Ok(guard) => Some(guard.entries().len()),
Err(_) => None, // turn in flight — skip the count
let (entries, report) = match st.storage.try_lock() {
Ok(guard) => (
Some(guard.entries().len()),
// Issue #211: additive usage report.
tole_cli::usage_report::usage_report(guard.usages()).to_json(),
),
// turn in flight — skip the count and report
Err(_) => (None, json!(null)),
};
(busy, entries)
(busy, entries, report)
};
let entries = match entries {
Some(n) => json!(n),
Expand All @@ -566,6 +571,7 @@ fn route(state: &State, method: &str, path: &str, body: &str) -> (u16, serde_jso
"id": id,
"entries": entries,
"busy": busy,
"usage_report": report,
}),
)
}
Expand Down
13 changes: 9 additions & 4 deletions crates/tole-cli/src/session_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,16 @@ impl Tool for SessionStatusTool {
use tole_core::storage::Storage;
let st = self.0.resolve(&input)?;
let busy = *st.busy.lock().expect("busy lock");
let entries = match st.storage.try_lock() {
Ok(guard) => json!(guard.entries().len()),
Err(_) => json!(null), // turn in flight
let (entries, report) = match st.storage.try_lock() {
Ok(guard) => (
json!(guard.entries().len()),
// Issue #211: additive usage report (same object as the
// serve status JSON).
crate::usage_report::usage_report(guard.usages()).to_json(),
),
Err(_) => (json!(null), json!(null)), // turn in flight
};
Ok(json!({ "busy": busy, "entries": entries }))
Ok(json!({ "busy": busy, "entries": entries, "usage_report": report }))
}
}

Expand Down
Loading
Loading