Skip to content
Merged
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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.
- Sibling change notices (#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. Children spawned by `agent_start` get `TOLE_AGENT_ID` and a shared `TOLE_AGENT_TOUCH_LOG`; in a child, `tole run` wraps its file tools and runs the watcher, so a sibling's change reaches the model at its next safe point.
- `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 (#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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ hard error.
| `verify_package` | RO | crates.io / npm registry check before any install — hallucinated names get NOT FOUND + candidates, edit-distance-1 candidates get a typo-squat warning |
| `load_skill` | RO | loads a discovered SKILL.md on demand (`--skill` pins one upfront; `--no-skills` disables) |
| `gitea` | Write | Gitea counterpart of `gh` over the instance REST API — registers when `origin` is a Gitea remote AND `TOLE_GITEA_TOKEN`/`GITEA_TOKEN` is set; same six ops |
| `agent_start`, `agent_poll` | Write / Write | depth-1 child agents: spawn durable child sessions (structurally no grandchildren), results via per-child uteke mailboxes (ephemeral by default); `agent_poll` is Write because a settled poll consumes (cleans) the mailbox, and `--trust internal` allowlists it by name; `--agents-worktree` gives each child its own git worktree |
| `agent_start`, `agent_poll` | Write / Write | depth-1 child agents: spawn durable child sessions (structurally no grandchildren), results via per-child uteke mailboxes (ephemeral by default); `agent_poll` is Write because a settled poll consumes (cleans) the mailbox, and `--trust internal` allowlists it by name; `--agents-worktree` gives each child its own git worktree; siblings are told (#380) when another child changes a file they read or wrote, at their next safe point |
| `systemone_decide` | RO | typed decisions (choice/score/noul + confidence) from a System One backend — active when `SYSTEMONE_API_KEY` is set; `SYSTEMONE_BASE_URL` picks the backend (hosted Jev default, self-hosted compatible) |
| `job_start`, `job_poll` | Write / RO | detached long-running jobs with log tailing |
| `cora_search` | RO | hybrid codebase search via `cora brain`; native fallback — skipped when the cora MCP surface is attached |
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 @@ -10,6 +10,7 @@ pub mod config_trust;
pub mod doctor;
pub mod session_host;
pub mod session_tools;
pub mod sibling;
pub mod tools;
pub mod trust;
pub mod usage_report;
65 changes: 51 additions & 14 deletions crates/tole-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,8 @@ fn build_registry(
#[cfg_attr(not(feature = "shell-tools"), allow(unused_variables))]
let cwd = std::env::current_dir().context("resolving cwd")?;
let file_root = resolve_workspace_root(workspace)?;
// Child agents (#380): present only when spawned by `agent_start`.
let tracking = tole_cli::sibling::tracking_from_env(&tole_cli::config_apply::real_env);
// Uteke first-class (B4): recall (read) + document (write), behind
// startup probing — a missing uteke binary degrades to a warning,
// not phantom tools.
Expand Down Expand Up @@ -1543,8 +1545,12 @@ fn build_registry(
)))
.map_err(|e| anyhow::anyhow!("registering agent_poll: {e}"))?;
}
reg.register(Box::new(ReadFileTool::new(file_root.clone())))
.map_err(|e| anyhow::anyhow!("registering read_file: {e}"))?;
reg.register(tole_cli::sibling::track(
Box::new(ReadFileTool::new(file_root.clone())),
tracking.as_ref(),
&file_root,
))
.map_err(|e| anyhow::anyhow!("registering read_file: {e}"))?;
// Web tools (issue #215): probe-first — fetch always; search only
// with TOLE_WEB_SEARCH_URL.
reg.register(Box::new(tole_core::web::WebFetchTool))
Expand All @@ -1554,10 +1560,18 @@ fn build_registry(
.map_err(|e| anyhow::anyhow!("registering web_search: {e}"))?;
}
// Write tools: gated per call. The jail root is the workspace.
reg.register(Box::new(WriteFileTool::new(file_root.clone())))
.map_err(|e| anyhow::anyhow!("registering write_file: {e}"))?;
reg.register(Box::new(EditFileTool::new(file_root.clone())))
.map_err(|e| anyhow::anyhow!("registering edit_file: {e}"))?;
reg.register(tole_cli::sibling::track(
Box::new(WriteFileTool::new(file_root.clone())),
tracking.as_ref(),
&file_root,
))
.map_err(|e| anyhow::anyhow!("registering write_file: {e}"))?;
reg.register(tole_cli::sibling::track(
Box::new(EditFileTool::new(file_root.clone())),
tracking.as_ref(),
&file_root,
))
.map_err(|e| anyhow::anyhow!("registering edit_file: {e}"))?;
#[cfg(feature = "shell-tools")]
{
register_gh(&mut reg, &cwd)?;
Expand All @@ -1569,8 +1583,12 @@ fn build_registry(
.map_err(|e| anyhow::anyhow!("registering git: {e}"))?;
// Destructive tools: interactive-approver-only registration; every
// call prompts — allowlists and --yes never apply (PRD risk table).
reg.register(Box::new(DeleteFileTool::new(file_root)))
.map_err(|e| anyhow::anyhow!("registering delete_file: {e}"))?;
reg.register(tole_cli::sibling::track(
Box::new(DeleteFileTool::new(file_root.clone())),
tracking.as_ref(),
&file_root,
))
.map_err(|e| anyhow::anyhow!("registering delete_file: {e}"))?;
// MCP servers (#74): registered last so a slow server never blocks
// native tool availability; per-call approval applies as usual.
#[cfg(feature = "mcp")]
Expand Down Expand Up @@ -1624,6 +1642,8 @@ fn build_server_registry(
ToolRegistry::with_approver(AllowlistApprover::allow_only(allow_patterns.to_vec()));
let cwd = std::env::current_dir().context("resolving cwd")?;
let file_root = resolve_workspace_root(workspace)?;
// Child agents (#380): present only when spawned by `agent_start`.
let tracking = tole_cli::sibling::tracking_from_env(&tole_cli::config_apply::real_env);
let count = |reg: &ToolRegistry| reg.specs().len();

#[cfg(feature = "shell-tools")]
Expand Down Expand Up @@ -1675,8 +1695,12 @@ fn build_server_registry(
reg.register(Box::new(t))
.map_err(|e| anyhow::anyhow!("registering systemone_decide: {e}"))?;
}
reg.register(Box::new(ReadFileTool::new(file_root.clone())))
.map_err(|e| anyhow::anyhow!("registering read_file: {e}"))?;
reg.register(tole_cli::sibling::track(
Box::new(ReadFileTool::new(file_root.clone())),
tracking.as_ref(),
&file_root,
))
.map_err(|e| anyhow::anyhow!("registering read_file: {e}"))?;
// Web tools (issue #215): probe-first — fetch always; search only
// with TOLE_WEB_SEARCH_URL.
reg.register(Box::new(tole_core::web::WebFetchTool))
Expand All @@ -1685,10 +1709,18 @@ fn build_server_registry(
reg.register(Box::new(tole_core::web::WebSearchTool::from_env().unwrap()))
.map_err(|e| anyhow::anyhow!("registering web_search: {e}"))?;
}
reg.register(Box::new(WriteFileTool::new(file_root.clone())))
.map_err(|e| anyhow::anyhow!("registering write_file: {e}"))?;
reg.register(Box::new(EditFileTool::new(file_root.clone())))
.map_err(|e| anyhow::anyhow!("registering edit_file: {e}"))?;
reg.register(tole_cli::sibling::track(
Box::new(WriteFileTool::new(file_root.clone())),
tracking.as_ref(),
&file_root,
))
.map_err(|e| anyhow::anyhow!("registering write_file: {e}"))?;
reg.register(tole_cli::sibling::track(
Box::new(EditFileTool::new(file_root.clone())),
tracking.as_ref(),
&file_root,
))
.map_err(|e| anyhow::anyhow!("registering edit_file: {e}"))?;
#[cfg(feature = "shell-tools")]
{
register_gh(&mut reg, &cwd)?;
Expand Down Expand Up @@ -1930,6 +1962,11 @@ fn run_command(
// token at the deadline; the loop's checkpoints unwind it into a
// durable Cancelled — resumable, never dead.
let cancel = tole_core::cancel::CancelToken::default();
// Child agents (#380): fold the shared touch log and queue a notice at
// the next safe point when a sibling changes a file this child read or
// wrote. A no-op outside a child.
let _sibling_watcher = tole_cli::sibling::tracking_from_env(&tole_cli::config_apply::real_env)
.map(|t| tole_cli::sibling::start_watcher(&t, &cancel, tole_cli::sibling::POLL_INTERVAL));
let timer = timeout_secs.map(|secs| {
let token = cancel.clone();
std::thread::spawn(move || {
Expand Down
185 changes: 185 additions & 0 deletions crates/tole-cli/src/sibling.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! Host wiring for sibling change notices (#380).
//!
//! A child agent (spawned by `agent_start`) finds its identity and the
//! shared touch log in its environment. When present, its file tools are
//! wrapped so every successful access is recorded, and a watcher thread
//! folds the shared log and queues a notice into the run's soft-interrupt
//! queue when a sibling changes a file this child read or wrote. Outside a
//! child (no env) everything here is a no-op.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tole_core::cancel::CancelToken;
use tole_core::sibling_watch::{
SiblingWatcher, TouchRecorder, Tracked, ENV_AGENT_ID, ENV_TOUCH_LOG,
};
use tole_core::tool::Tool;

/// How often the watcher folds the shared log. Notices are only ever
/// delivered at the turn loop's next safe point, so this bounds latency,
/// not correctness.
pub const POLL_INTERVAL: Duration = Duration::from_millis(300);

/// A child's identity and the shared log it reports to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tracking {
pub agent: String,
pub log: PathBuf,
}

/// Read the child identity from the environment. `None` when this is not
/// a tracked child, or when the id is not a well-formed agent id (never
/// trusted blindly: it ends up in the shared log).
pub fn tracking_from_env(env: &dyn Fn(&str) -> Option<String>) -> Option<Tracking> {
let agent = env(ENV_AGENT_ID)?.trim().to_string();
let log = env(ENV_TOUCH_LOG)?.trim().to_string();
if !tole_core::sibling_watch::valid_agent_id(&agent) || log.is_empty() {
return None;
}
Some(Tracking {
agent,
log: PathBuf::from(log),
})
}

/// Wrap a file tool so its successful accesses are recorded; identity when
/// not tracking.
pub fn track(tool: Box<dyn Tool>, tracking: Option<&Tracking>, root: &Path) -> Box<dyn Tool> {
match tracking {
Some(t) => Box::new(Tracked::new(
tool,
TouchRecorder::new(t.agent.clone(), t.log.clone(), root),
)),
None => tool,
}
}

/// Background watcher; stops when dropped.
pub struct Watcher {
stop: Arc<AtomicBool>,
}

impl Drop for Watcher {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
}
}

/// Start folding the shared log, queueing a notice into `cancel`'s
/// soft-interrupt queue for every sibling change. A full queue drops the
/// notice (it is informational); a read error is reported once.
pub fn start_watcher(tracking: &Tracking, cancel: &CancelToken, every: Duration) -> Watcher {
let stop = Arc::new(AtomicBool::new(false));
let (flag, token) = (Arc::clone(&stop), cancel.clone());
let mut watcher = SiblingWatcher::new(tracking.agent.clone(), tracking.log.clone());
std::thread::spawn(move || {
let mut reported = false;
while !flag.load(Ordering::SeqCst) {
match watcher.poll() {
Ok(notices) => {
for n in notices {
let _ = token.interrupts().push_notice(&n.text());
}
}
Err(e) if !reported => {
reported = true;
eprintln!("tole: sibling tracking: cannot read the touch log: {e}");
}
Err(_) => {}
}
std::thread::sleep(every);
}
});
Watcher { stop }
}

#[cfg(test)]
mod tests {
use super::*;
use tole_core::sibling_watch::Op;

fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k| {
pairs
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.to_string())
}
}

#[test]
fn tracking_needs_a_well_formed_id_and_a_log() {
let ok = env_of(&[(ENV_AGENT_ID, "a-1f-2-0"), (ENV_TOUCH_LOG, "/x/touch.log")]);
assert_eq!(
tracking_from_env(&ok),
Some(Tracking {
agent: "a-1f-2-0".into(),
log: "/x/touch.log".into()
})
);
assert_eq!(tracking_from_env(&env_of(&[])), None);
assert_eq!(
tracking_from_env(&env_of(&[(ENV_AGENT_ID, "a-1")])),
None,
"no log"
);
for bad in ["", "A-1", "a b", "a\n[x]", "../x"] {
let pairs = [(ENV_AGENT_ID, bad), (ENV_TOUCH_LOG, "/x")];
let e = env_of(&pairs);
assert_eq!(tracking_from_env(&e), None, "{bad:?}");
}
}

#[test]
fn the_watcher_turns_a_sibling_write_into_a_queued_notice() {
let dir = std::env::temp_dir().join(format!("tole-sibwire-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let log = dir.join("tole-agents").join("touch.log");
let mine = Tracking {
agent: "a-1".into(),
log: log.clone(),
};
let cancel = CancelToken::default();
let _w = start_watcher(&mine, &cancel, Duration::from_millis(20));

let me = TouchRecorder::new("a-1", &log, "/ws");
let sib = TouchRecorder::new("a-2", &log, "/ws");
me.record(Op::Read, "src/lib.rs").unwrap();
sib.record(Op::Write, "src/lib.rs").unwrap();

let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut got = Vec::new();
while got.is_empty() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
got = cancel.interrupts().drain();
}
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].source, tole_core::interrupt::Source::Notice);
assert!(got[0].text.contains("child agent a-2"), "{}", got[0].text);
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn track_is_the_identity_outside_a_child() {
struct T;
impl Tool for T {
fn name(&self) -> &str {
"read_file"
}
fn risk(&self) -> tole_core::tool::Risk {
tole_core::tool::Risk::ReadOnly
}
fn execute(&self, i: serde_json::Value) -> Result<serde_json::Value, String> {
Ok(i)
}
}
let t = track(Box::new(T), None, Path::new("/ws"));
assert_eq!(t.name(), "read_file");
assert_eq!(
t.execute(serde_json::json!({"path": "x"})).unwrap()["path"],
"x"
);
}
}
Loading
Loading