Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cfe4e26
chore(deps): pin base64 to 0.22 to avoid a duplicate dependency
senamakel Aug 30, 2026
a252ec0
chore: files changed Cargo.lock
senamakel Aug 30, 2026
4d3fb92
fix(select): separate send resource nouns from action verbs
senamakel Aug 30, 2026
e46f546
chore: files changed src/harness/tool/select/test.rs
senamakel Aug 30, 2026
5ce61c2
chore: files changed src/graph/delegation/run.rs
senamakel Aug 30, 2026
2901e24
feat(delegation): add test for schema-mismatched checkpoint rejection…
senamakel Aug 30, 2026
778f90c
test(delegation): add concurrency test for resume_delegation serializ…
senamakel Aug 30, 2026
cc43d8d
feat(delegation): add cancellation check before approval node
senamakel Aug 30, 2026
85f16ee
test(delegation): add test for cancellation at approval node routing …
senamakel Aug 30, 2026
15f93ed
chore: files changed src/graph/checkpoint/mod.rs
senamakel Aug 30, 2026
aa80d52
test(delegation): add test ensuring only record context gets schema tag
senamakel Aug 30, 2026
6bade4b
feat(dag): document silent edge drop for duplicate node ids
senamakel Aug 30, 2026
b726a58
fix(dag): clarify duplicate-id behaviour in has_cycle docs
senamakel Aug 30, 2026
7575dbe
fix(select): restore Send verb detection for noun-only matches
senamakel Aug 30, 2026
f5af08c
chore: files changed src/harness/tool/select/mod.rs
senamakel Aug 30, 2026
a578da8
chore: files changed src/graph/delegation/test.rs
senamakel Aug 30, 2026
0a0368b
chore: files changed src/graph/delegation/run.rs
senamakel Aug 30, 2026
a10c9ce
chore: files changed src/graph/delegation/run.rs
senamakel Aug 30, 2026
a7a5619
chore: files changed src/harness/tool/select/test.rs
senamakel Aug 30, 2026
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
12 changes: 3 additions & 9 deletions Cargo.lock

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

14 changes: 13 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,19 @@ chrono-tz = { version = "0.10", optional = true }
# `STANDARD_NO_PAD`, so `harness::multimodal::data_uri` picks by length instead
# of trying both — trying both decoded a multi-MB image twice for every
# unpadded payload.
base64 = { version = "0.23", optional = true }
#
# Pinned to `0.22`, one minor behind what dependabot proposed (#125), on
# purpose: `hyper-util` -> `hyper-rustls` -> `reqwest` already pulls `0.22.1`
# into every consumer's graph (openhuman, tinyagents, tinycortex, tinymemory),
# so a direct `0.23` requirement here resolves as a SECOND copy rather than
# reusing that one — +1 package with no capability gained, which is exactly
# what trips a consumer's kernel-dependency-floor ratchet (see
# `tinyhumansai/openhuman`'s `scripts/kernel-floor.limits`). Unify with the
# ecosystem's version instead of letting this crate's direct dependency get
# ahead of it. Dependabot will likely re-propose `0.23` once `reqwest`'s own
# tree moves past `0.22` — that will be fine to accept at that point, since it
# will no longer create a duplicate.
base64 = { version = "0.22", optional = true }
# `flate2` decompresses `application/gzip` data URIs that carry an
# `original_mime=` parameter — the shape a renderer emits when it compresses an
# attachment before handing it over. Decompression is bounded by `Read::take`
Expand Down
39 changes: 34 additions & 5 deletions src/graph/checkpoint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,45 @@ use crate::{Result, TinyAgentsError};
/// prefix; `what` names the field being decoded (e.g. `"record"`,
/// `"next_nodes"`) with no leading `"decode "` — this function supplies that
/// wording once, alongside the tag.
///
/// **Only `what == "record"` — the whole-`Checkpoint<State>` decode, where
/// `State` is the one thing this module knows can legitimately evolve across
/// a caller's schema versions — is eligible for `[schema]` at all.**
/// `"namespace"`, `"next_nodes"`, `"header"` and `"write payload"` are
/// checkpoint-internal engine metadata with no versioned shape of their own;
/// a `Category::Data` failure decoding any of those is corruption by
/// construction; classifying it as `[schema]` and pruning would have been
/// simply wrong.
///
/// Even for `"record"`, `Category::Data` is a HEURISTIC, not a proof: this
/// module is generic over `State` (see the module docs) and has no way to
/// positively identify a specific caller's known-legacy shape from a byte
/// stream alone — a decode failure produced by data corruption that happens
/// to still be syntactically valid JSON (e.g. a `state.schema_version` field
/// whose value was flipped from a number to a string) also classifies as
/// `Category::Data`, indistinguishable at this layer from a genuine older
/// schema. This is an accepted, bounded residual risk, not a claim of
/// perfect precision: the worst case is an unnecessary fresh restart from
/// `plan` for what was actually corruption, which is a strict improvement
/// over the state before this classifier existed (100% of decode failures,
/// corrupt or not, were pruned identically). Closing the remaining gap
/// precisely would need the generic `Checkpointer` trait to expose the raw
/// bytes on a failed decode so a caller could apply its OWN positive-shape
/// check — a real API addition, tracked as follow-up rather than done here.
pub(super) fn decode_json_err(
backend: &str,
what: &str,
err: serde_json::Error,
) -> TinyAgentsError {
let tag = match err.classify() {
serde_json::error::Category::Data => "schema",
serde_json::error::Category::Syntax
| serde_json::error::Category::Eof
| serde_json::error::Category::Io => "corrupt",
let tag = if what == "record" {
match err.classify() {
serde_json::error::Category::Data => "schema",
serde_json::error::Category::Syntax
| serde_json::error::Category::Eof
| serde_json::error::Category::Io => "corrupt",
}
} else {
"corrupt"
};
TinyAgentsError::Checkpoint(format!("{backend}: decode [{tag}] {what}: {err}"))
}
Expand Down
18 changes: 16 additions & 2 deletions src/graph/dag/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ pub use types::{DagIssue, DagNode};
/// Reports whether the dependency edges contain a cycle.
///
/// Edges naming an id that is not a declared node are ignored (see the module
/// docs). Duplicate ids are collapsed to one node, so they cannot produce a
/// false positive.
/// docs). Duplicate ids are collapsed to one node (first declaration wins),
/// so they cannot produce a false positive — but a caller that wants to add
/// edges to a node id that already exists must merge them into that one
/// declaration; a second `DagNode` with the same id has its edges silently
/// dropped instead of contributing to the cycle check (see the comment at
/// the dedupe below).
pub fn has_cycle(nodes: &[DagNode<'_>]) -> bool {
let ids: HashSet<&str> = nodes.iter().map(|n| n.id).collect();
let mut indegree: HashMap<&str, usize> = ids.iter().map(|&id| (id, 0)).collect();
Expand All @@ -75,6 +79,16 @@ pub fn has_cycle(nodes: &[DagNode<'_>]) -> bool {
// two conflicting declarations of the same id (e.g. `a[]` and `a[b]`) get
// merged into edges that no single declaration actually names, which can
// fabricate a cycle that would not exist under either declaration alone.
//
// Consequence for callers: a caller that wants to add dependency edges to
// a node that already exists in `nodes` must merge those edges into that
// node's single `DagNode` declaration, not append a second `DagNode` with
// the same id and the new edges — the second declaration's edges are
// silently dropped here (first declaration wins), so a real cycle they
// would have introduced goes undetected. This is unreachable in a caller
// that only ever mints fresh, never-reused ids (e.g. a fresh UUID per
// node), but is a live footgun for any caller that revisits an existing
// id.
let mut processed: HashSet<&str> = HashSet::with_capacity(ids.len());
for node in nodes {
if !processed.insert(node.id) {
Expand Down
17 changes: 17 additions & 0 deletions src/graph/delegation/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,25 @@ where
// pause via `ApprovalRequestCard`), which parks a live in-memory chat turn
// and is deliberately left untouched.
if require_review_approval {
let cancel_approval = cancel.clone();
builder = builder.add_node("approval", move |s: DelegationState, ctx: NodeContext| {
let cancel = cancel_approval.clone();
async move {
// Cancellation must be honoured here exactly like every other
// node: without this check, a cancellation that arrives while
// a gated run is waiting for approval (or during the
// preceding review worker) is invisible to this boundary. A
// decisionless retry would then interrupt again indefinitely
// (nothing ever routes it to `finalize`), while an approving
// resume would finalize successfully despite the run having
// been cancelled.
if cancel.is_cancelled() {
return Ok(NodeResult::Command(
Command::default()
.with_update(DelegationUpdate::Cancelled)
.with_goto(["finalize"]),
));
}
match ctx.resume {
None => {
let payload = json!({
Expand Down
42 changes: 42 additions & 0 deletions src/graph/delegation/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,48 @@ where
F: Fn(DelegationStage, DelegationState) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = Result<DelegationStageOutput, String>> + Send + 'static,
{
// This is the public entry point a host calls directly with an approver's
// decision — it does NOT go through `run_or_resume_delegation`, so it must
// provide its own two protections that function otherwise only gets:
//
// 1. Per-thread serialization: two concurrent approval callbacks for the
// SAME paused thread (a retried callback racing with a deny, say) must
// not both load the same interrupt checkpoint and independently
// finalize opposite decisions, appending competing histories. Uses the
// SAME `thread_lock` map as `run_or_resume_delegation`, so the two
// entry points serialize against each other too, not just themselves.
// 2. Schema-version validation: `CompiledGraph::resume` (inside
// `resume_graph`) applies the decision to whatever checkpoint it
// finds, with no schema check of its own — that check lives in
// `run_or_resume_delegation`'s match arms, which this path bypasses.
// During a rollback or mixed-version deployment, serde can decode a
// checkpoint written by a newer binary by ignoring its added fields,
// letting an older binary consume the approval and finalize the state
// under outdated semantics. Reject rather than guess.
let thread_id = config
.thread_id
.clone()
.ok_or_else(|| "delegation resume requires a thread_id".to_string())?;
let cp = config
.checkpointer
.clone()
.ok_or_else(|| "delegation resume requires a checkpointer".to_string())?;

let lock = thread_lock(&thread_id);
let _guard = lock.lock().await;

if let Some(checkpoint) = cp
.get(thread_id.as_str(), None)
.await
.map_err(|e| format!("delegation checkpoint read failed for thread {thread_id}: {e}"))?
&& checkpoint.state.schema_version != CURRENT_SCHEMA_VERSION
Comment on lines +190 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the checkpoint in the root namespace

When the checkpointer contains multiple namespaces for this thread, get returns the latest checkpoint across the entire thread, while resume_graph ultimately calls CompiledGraph::resume, which loads the latest checkpoint scoped to the graph's root namespace. A newer subgraph checkpoint can therefore cause this guard either to reject a compatible root approval or to validate the wrong schema version and then apply the decision to an incompatible root checkpoint. Use get_scoped(thread_id.as_str(), None, &[]) here so the validation and resume target the same record.

Useful? React with 👍 / 👎.

{
return Err(format!(
"delegation resume refused for thread {thread_id}: checkpoint schema_version {} does not match this binary's schema_version {} — the approval decision was NOT applied",
checkpoint.state.schema_version, CURRENT_SCHEMA_VERSION
));
}

let approved = decision_is_approve(&decision);
tracing::info!(
approved,
Expand Down
Loading