Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
89490d8
test(graph): cover bound continuation across resume and retry
senamakel Sep 19, 2026
420cab0
feat(session): own transcript layout migration
senamakel Sep 19, 2026
0a44f32
feat(session): append interrupted partials atomically
senamakel Sep 19, 2026
6d611da
feat(runtime): add host-neutral stateful sessions
senamakel Sep 19, 2026
c90dca1
feat(orchestration): add neutral subagent lifecycle
senamakel Sep 19, 2026
2d19206
feat(runtime): support host-prepared session turns
senamakel Sep 19, 2026
766af06
feat(runtime): stage transcript resume before turn preparation
senamakel Sep 19, 2026
612e4e8
feat(orchestration): carry explicit subagent request identity
senamakel Sep 19, 2026
7e108c7
feat(orchestration): export subagent request parts
senamakel Sep 19, 2026
6930dc5
feat(runtime): persist codec-provided turn usage atomically
senamakel Sep 19, 2026
2a8e69b
Merge remote-tracking branch 'origin/main' into migrate-agents-to-tin…
senamakel Sep 19, 2026
7d72ecc
feat(orchestration): add durable host-neutral subagent lifecycle
senamakel Sep 19, 2026
e1b2035
refactor(todos): remove static agent assignment surface
senamakel Sep 19, 2026
9e5a1ed
fix(session): compile transcript marker helper only in tests
senamakel Sep 19, 2026
452bd52
Merge remote-tracking branch 'origin/main' into migrate-agents-to-tin…
senamakel Sep 19, 2026
b1d6804
fix(runtime): align tinytools dependency with vendored release
senamakel Sep 19, 2026
fc935e6
fix: close subagent lifecycle review races
senamakel Sep 19, 2026
1125ee2
fix: make dropped reservation cleanup runtime-independent
senamakel Sep 19, 2026
746abc6
fix: address runtime lifecycle review findings
senamakel Sep 19, 2026
f3f2c64
docs: correct task dispatch selection contract
senamakel Sep 19, 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
19 changes: 19 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ default-members = [
"crates/tinyagents-graph",
"crates/tinyagents-registry",
"crates/tinyagents-session",
"crates/tinyagents-runtime",
"crates/tinyagents-orchestration",
]
exclude = ["vendor", "worktrees"]
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ TinyAgents is a Cargo workspace, not one crate. Depend on the pieces you need:
name, plus an offline model price/capability catalog.
- **`tinyagents-session`** — a SQLite-backed store for session history,
messages, tool calls, cost, and run lineage.
- **`tinyagents-runtime`** — host-neutral stateful turns over the harness and
append-only transcript seam; hosts retain policy, prompt composition,
authorization, and durable-dialect conversion.
- **`tinyagents-tracing`** — the `tracing` macros the other crates gate behind
their `tracing` feature. Compiled out by default.
- **`tinyagents-integration-tests`** — cross-crate tests and the runnable
Expand Down Expand Up @@ -146,6 +149,20 @@ wrapped as a tool and handed to another agent (`SubAgent` /
`SubAgentSession` / `SubAgentTool`), which is how multi-agent orchestration
is composed — plain function composition, not a distinct execution mode.

## Session runtime

`tinyagents-runtime` owns mutable model history for one host-owned
conversation, a stable prompt prefix, a frozen tool declaration snapshot, and
the sequencing around one append-only transcript commit. A host supplies the
driver, its lossless transcript codec, and lifecycle hooks. On a driver error,
the runtime can commit recoverable logical history with an interrupted,
display-only partial in the same history operation; model-context replay omits
that partial. A codec can also derive `TurnUsage` from its explicit host
context after the driver runs; that usage is attached to the same atomic
append's final assistant row for both success and recoverable partials. A
post-commit hook observes durable successes but cannot change their result.
See [the runtime module](docs/modules/runtime/README.md).

## Registry

`tinyagents-registry` is a name-addressable catalog of models, tools, agents,
Expand Down
6 changes: 6 additions & 0 deletions crates/tinyagents-graph/src/compiled/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ Accessors: `graph_id()`, `name()`, `namespace()`.
failure boundary.
- `retry(thread_id)` — re-run the failed node(s) recorded in the last
failure-boundary checkpoint.
- `run_with_agent_binding(..)`, `run_with_thread_agent_binding(..)`,
`resume_with_agent_binding(..)`, `resume_from_with_agent_binding(..)`, and
`retry_with_agent_binding(..)` — execution-scoped variants for graphs that
reach a `SubAgentNode`. The supplied `AgentInvocationBinding` is forwarded
to resumed nodes and nested subgraphs, but is never stored on the reusable
graph or in a checkpoint; an unbound sub-agent continuation fails closed.

### State inspection / time travel

Expand Down
238 changes: 237 additions & 1 deletion crates/tinyagents-graph/src/compiled/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ use crate::checkpoint::{Checkpointer, InMemoryCheckpointer};
use crate::command::{Command, Interrupt, NodeResult, Send};
use crate::reducer::ClosureStateReducer;
use crate::stream::{CollectingSink, GraphEvent};
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tinyagents_harness::cancel::CancellationToken;
use tinyagents_harness::events::{AgentEvent, EventSink, RecordingListener};
use tinyagents_harness::ids::ExecutionStatus;
use tinyagents_harness::retry::RetryPolicy;

Expand All @@ -21,6 +24,42 @@ struct Counter {
log: Vec<String>,
}

/// A host invoker whose recorded request makes a continuation's live binding
/// observable without putting host capabilities in durable graph state.
#[derive(Clone, Default)]
struct BindingRecordingInvoker(Arc<Mutex<Vec<crate::AgentInvocation>>>);

#[async_trait]
impl crate::AgentInvoker for BindingRecordingInvoker {
async fn invoke(
&self,
request: crate::AgentInvocation,
) -> crate::Result<crate::SubAgentOutput> {
self.0.lock().unwrap().push(request.clone());
request.events.emit(AgentEvent::StateUpdate);
Ok(crate::SubAgentOutput {
text: request.input.prompt,
..Default::default()
})
}
}

struct BindingFailingInvoker;

#[async_trait]
impl crate::AgentInvoker for BindingFailingInvoker {
async fn invoke(
&self,
_request: crate::AgentInvocation,
) -> crate::Result<crate::SubAgentOutput> {
Err(TinyAgentsError::Model("continuation failure".to_string()))
}
}

fn agent_binding(invoker: Arc<dyn crate::AgentInvoker>) -> crate::AgentInvocationBinding {
crate::AgentInvocationBinding::new(invoker, EventSink::new(), CancellationToken::new())
}

/// Builds a graph whose nodes return partial `i32` updates merged by a custom
/// reducer that adds to `value` and records a log entry.
fn adding_graph() -> CompiledGraph<Counter, i32> {
Expand Down Expand Up @@ -796,6 +835,203 @@ async fn resume_from_older_checkpoint_replays_forward() {
assert!(matches!(err, TinyAgentsError::Resume(_)));
}

#[tokio::test]
async fn resume_from_with_agent_binding_keeps_host_capabilities_live_only() {
// A bound run pauses before delegation. Its checkpoint must be enough to
// resume graph state, but must never retain the invoker, event sink, or
// cancellation handle that happened to start the run.
let checkpointer = Arc::new(InMemoryCheckpointer::<String>::new());
let graph = GraphBuilder::<String, String>::overwrite()
.add_node("gate", |state: String, ctx: NodeContext| async move {
if state == "unbound" || ctx.resume.is_some() {
Ok(NodeResult::Update(state))
} else {
Ok(NodeResult::Interrupt(Interrupt::new(
"gate",
json!({ "ask": "continue?" }),
)))
}
})
.add_node(
"delegate",
crate::subagent_node(crate::SubAgentNode::from_fns(
"researcher",
|state: &String| crate::SubAgentInput::prompt(state.clone()),
|output: crate::SubAgentOutput| output.text,
)),
)
.set_entry("gate")
.add_edge("gate", "delegate")
.set_finish("delegate")
.compile()
.unwrap()
.with_checkpointer(checkpointer.clone());

let initial_invoker = Arc::new(BindingRecordingInvoker::default());
let paused = graph
.run_with_thread_agent_binding(
"resume-binding",
"question".to_string(),
agent_binding(initial_invoker),
)
.await
.unwrap();
assert!(paused.is_interrupted());

let checkpoint = checkpointer
.get("resume-binding", None)
.await
.unwrap()
.expect("interrupt is checkpointed");
let interrupted_checkpoint_id = checkpoint.checkpoint_id.clone();
let checkpoint_json = serde_json::to_value(checkpoint).unwrap();
assert!(checkpoint_json.get("agent_binding").is_none());
assert!(
!checkpoint_json
.to_string()
.contains("AgentInvocationBinding"),
"a durable checkpoint must contain no live invocation capability"
);

// Reloading this bound checkpoint without a fresh binding reaches the
// SubAgentNode but must fail closed. Retrying the resulting failure
// boundary without a binding must do the same; neither continuation may
// recover capabilities from the original bound execution.
let unbound_resume = graph
.resume("resume-binding", Command::resume(json!("approved")))
.await
.unwrap_err();
assert!(matches!(unbound_resume, TinyAgentsError::Capability(_)));
assert!(
unbound_resume
.to_string()
.contains("sub-agent `researcher`"),
"unbound resume must fail at SubAgentNode: {unbound_resume}"
);

let unbound_retry = graph.retry("resume-binding").await.unwrap_err();
assert!(matches!(unbound_retry, TinyAgentsError::Capability(_)));
assert!(
unbound_retry.to_string().contains("sub-agent `researcher`"),
"unbound retry must fail at SubAgentNode: {unbound_retry}"
);

let latest_after_unbound_failures = checkpointer
.get("resume-binding", None)
.await
.unwrap()
.expect("unbound failures are checkpointed")
.checkpoint_id;
assert_ne!(
latest_after_unbound_failures, interrupted_checkpoint_id,
"the selected interrupt checkpoint must no longer be latest"
);

let invoker = Arc::new(BindingRecordingInvoker::default());
let events = EventSink::new();
let listener = Arc::new(RecordingListener::new());
events.subscribe(listener.clone());
let cancellation = CancellationToken::new();
let resumed = graph
.resume_from_with_agent_binding(
"resume-binding",
ResumeTarget::Checkpoint(interrupted_checkpoint_id),
Command::resume(json!("approved")),
crate::AgentInvocationBinding::new(invoker.clone(), events, cancellation.clone()),
)
.await
.unwrap();

assert_eq!(resumed.state, "question");
let request = invoker.0.lock().unwrap().pop().expect("delegate ran");
assert_eq!(request.parent_run_id, resumed.run_id);
assert_eq!(request.root_run_id, resumed.root_run_id);
let request_cancellation = request.cancellation.expect("binding supplies cancellation");
assert!(
!request_cancellation.is_cancelled(),
"the captured request must initially observe the supplied live token"
);
cancellation.cancel();
assert!(
request_cancellation.is_cancelled(),
"cancelling the supplied token after invocation must reach the captured request"
);
assert_eq!(listener.len(), 1);
}

#[tokio::test]
async fn retry_with_agent_binding_replaces_failed_run_capabilities() {
let checkpointer = Arc::new(InMemoryCheckpointer::<String>::new());
let graph = GraphBuilder::<String, String>::overwrite()
.add_node(
"delegate",
crate::subagent_node(crate::SubAgentNode::from_fns(
"researcher",
|state: &String| crate::SubAgentInput::prompt(state.clone()),
|output: crate::SubAgentOutput| output.text,
)),
)
.set_entry("delegate")
.set_finish("delegate")
.compile()
.unwrap()
.with_checkpointer(checkpointer);

let failed = graph
.run_with_thread_agent_binding(
"retry-binding",
"question".to_string(),
agent_binding(Arc::new(BindingFailingInvoker)),
)
.await
.unwrap_err();
assert!(matches!(failed, TinyAgentsError::Model(_)));

// Give every capability a distinct observable behavior: the replacement
// invoker records the retry and its event sink has this listener only. The
// live cancellation handle is checked after the invocation below, proving
// the request received this exact replacement token.
let replacement = Arc::new(BindingRecordingInvoker::default());
let events = EventSink::new();
let listener = Arc::new(RecordingListener::new());
events.subscribe(listener.clone());
let cancellation = CancellationToken::new();
let retried = graph
.retry_with_agent_binding(
"retry-binding",
crate::AgentInvocationBinding::new(replacement.clone(), events, cancellation.clone()),
)
.await
.unwrap();

assert_eq!(retried.state, "question");
let request = replacement
.0
.lock()
.unwrap()
.pop()
.expect("retry delegated");
assert_eq!(request.parent_run_id, retried.run_id);
assert_eq!(request.root_run_id, retried.root_run_id);
let request_cancellation = request
.cancellation
.expect("retry binding supplies a cancellation token");
assert!(
!request_cancellation.is_cancelled(),
"the captured retry request must initially observe a live token"
);
cancellation.cancel();
assert!(
request_cancellation.is_cancelled(),
"cancelling the supplied token after invocation must reach the captured retry request"
);
assert_eq!(
listener.len(),
1,
"retry must forward the supplied event sink to the replacement invoker"
);
}

// --- Parallel (fan-out / fan-in) execution ---------------------------------

#[derive(Clone, Debug, Default, PartialEq)]
Expand Down
6 changes: 6 additions & 0 deletions crates/tinyagents-graph/src/subgraph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ both adapters check `execution.is_interrupted()` and return
`NodeResult::Interrupt(..)` instead of folding the paused child's state
through `from_child` (or returning it directly, for the shared-state case).

When a parent is resumed through one of `CompiledGraph`'s binding-aware
continuation APIs, both adapters forward that same live
`AgentInvocationBinding` into every resumed child branch. Bindings remain
execution-only data — they are not checkpointed — so a resumed child that
reaches a `SubAgentNode` without one fails closed.

## Namespace and recursion bookkeeping

Internal helpers (not part of the public surface, but load-bearing for
Expand Down
Loading
Loading