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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Sibling change notices, core (#380): `sibling_watch` tracks which files each child agent reads and writes in one shared append-only log (owner-only, bounded) and tells a child, once per stale path, when a sibling changed a file it had read or written; the `Tracked` tool decorator records `read_file` / `write_file` / `edit_file` / `delete_file` on success. Interrupts gain a `source`: a notice is delivered at the next safe point as an attributed entry (`source: notice`) and never withdraws output or supersedes a final answer. Wiring into `tole run` children follows separately.
- `tole doctor` coverage ledger (#378): every run is recorded per provider + model (level reached, first blocker, fix command, timestamp) in an owner-only `doctor-ledger.json` (atomic write, a corrupt file is an error, never replaced); `--summary` prints `READY n-of-m` + the first blocker and its fix, `--no-ledger` skips recording.
- Soft interrupts, core (#377): a bounded queue on the session's `CancelToken` that the turn loop drains at the safe point between a settled tool and the next provider call, committing each message as an attributed user-role entry (`interrupt: true`). An `urgent` interrupt (or any interrupt, for a final answer) withdraws an output that has not taken effect, so no tool call is left without its result, and records the drop durably. Host entry points (serve/MCP) follow separately.
- Soft interrupts (#377): a bounded queue on the session's `CancelToken` that the turn loop drains at the safe point between a settled tool and the next provider call, committing each message as an attributed user-role entry (`interrupt: true`). An `urgent` interrupt (or any interrupt, for a final answer) withdraws an output that has not taken effect, so no tool call is left without its result, and records the drop durably. Host entry points: `POST /sessions/{id}/interrupt` (`tole serve`) and the `tole_session_interrupt` MCP tool (`409`/error when no turn is running).
- `tole doctor` (#378): tiered provider diagnostics — `offline` (wiring), `catalog` (live `/models`, model must be served) and `full` (chat, streaming and a real tool-call loop) — each blocker carries its fix command, and keys / URL credentials are never printed. Exits non-zero when the requested tier is not reached; `--tier full` is the release-validation gate.
- Per-user-turn token stats (#379): `tole status` lists input / output / cache-read / cache-write / reasoning tokens per turn and the status JSON (`GET /sessions/{id}/status`, `tole_session_status`) gains an additive `usage_report.turns` array. Derived from the durable usage ledger, so old sessions read the same way and nothing is stored twice; a value no step reported is `n/a` / `null`, never 0, and no elapsed time is invented.
Expand Down
8 changes: 2 additions & 6 deletions crates/tole-core/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,8 @@ fn new_agent_id() -> String {
format!("a-{ms:x}-{:x}-{n:x}", std::process::id())
}

fn valid_agent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
pub(crate) fn valid_agent_id(id: &str) -> bool {
crate::sibling_watch::valid_agent_id(id)
}

fn agents_root(root: &std::path::Path) -> PathBuf {
Expand Down
50 changes: 46 additions & 4 deletions crates/tole-core/src/interrupt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ pub const MAX_PENDING: usize = 16;
/// Largest accepted interrupt text, in bytes.
pub const MAX_TEXT_BYTES: usize = 8 * 1024;

/// One owner message injected mid-turn.
/// Who queued an interrupt: the owner steering the turn, or the host
/// relaying an informational notice (e.g. a sibling agent changed a file,
/// #380). A notice never withdraws output and never supersedes a final
/// answer — it is delivered at the next safe point if the turn has one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Owner,
Notice,
}

/// One message injected mid-turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Interrupt {
pub text: String,
/// Also withdraw a tool call that has not started yet.
/// Also withdraw a tool call that has not started yet (owner only).
pub urgent: bool,
pub source: Source,
}

/// Why a push was refused.
Expand Down Expand Up @@ -64,6 +75,16 @@ impl InterruptQueue {
/// Queue a message. Bounded: blank, oversized, or beyond
/// [`MAX_PENDING`] pending are refused, never truncated.
pub fn push(&self, text: &str, urgent: bool) -> Result<(), PushError> {
self.push_from(text, urgent, Source::Owner)
}

/// Queue an informational notice (never urgent). Same bounds as
/// [`InterruptQueue::push`].
pub fn push_notice(&self, text: &str) -> Result<(), PushError> {
self.push_from(text, false, Source::Notice)
}

fn push_from(&self, text: &str, urgent: bool, source: Source) -> Result<(), PushError> {
let text = text.trim();
if text.is_empty() {
return Err(PushError::Empty);
Expand All @@ -78,6 +99,7 @@ impl InterruptQueue {
q.push_back(Interrupt {
text: text.to_string(),
urgent,
source,
});
Ok(())
}
Expand All @@ -91,6 +113,11 @@ impl InterruptQueue {
self.lock().is_empty()
}

/// Is an owner message waiting (a notice does not count)?
pub fn has_owner(&self) -> bool {
self.lock().iter().any(|i| i.source == Source::Owner)
}

/// Is an urgent interrupt waiting?
pub fn has_urgent(&self) -> bool {
self.lock().iter().any(|i| i.urgent)
Expand All @@ -113,18 +140,33 @@ mod tests {
vec![
Interrupt {
text: "first".into(),
urgent: false
urgent: false,
source: Source::Owner,
},
Interrupt {
text: "second".into(),
urgent: true
urgent: true,
source: Source::Owner,
},
]
);
assert!(q.is_empty() && !q.has_urgent());
assert!(q.drain().is_empty());
}

#[test]
fn a_notice_is_never_urgent_and_is_not_an_owner_message() {
let q = InterruptQueue::default();
q.push_notice("sibling changed a.rs").unwrap();
assert!(!q.has_urgent() && !q.has_owner() && !q.is_empty());
q.push("steer", false).unwrap();
assert!(q.has_owner());
let d = q.drain();
assert_eq!(d[0].source, Source::Notice);
assert!(!d[0].urgent);
assert_eq!(d[1].source, Source::Owner);
}

#[test]
fn clones_share_the_queue() {
let a = InterruptQueue::default();
Expand Down
1 change: 1 addition & 0 deletions crates/tole-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub mod replay;
pub mod run_command;
// Approval-surface text sanitizer (#327): crate-internal, no public API.
pub(crate) mod sanitize;
pub mod sibling_watch;
pub mod skills;
pub mod state;
pub mod storage;
Expand Down
Loading
Loading