Cluster Extension Points

A cluster host platform runs long-lived agent sessions across many nodes. The framework does not ship a scheduler or a placement engine. Instead it exposes a small set of seams: it defines the decision points, emits structured events, and lets the host supply the policy. Everything below is something you wire from outside the framework — you never fork it.

This page shows the equivalent native seams in Rust, Node.js, Python, and Go. The configuration shape follows each language's conventions, while the underlying capability and lifecycle contract stay the same.

Identity labels

Every session can carry four opaque identity labels. The framework never interprets them — it propagates them to hooks, traces, and SessionData, and restores them on resume. This is how a host attributes a session to a tenant, a principal, an agent template, and a wider correlation chain.

Pair identity labels with a sessionStore / session_store so the labels survive a process restart. On resume, caller-supplied options win, so you can relabel a session as you move it between nodes.

Rust
Node.js
Python
Go
Rust
use a3s_code_core::{Agent, SessionOptions};
#[tokio::main]
async fn main() -> a3s_code_core::Result<()> {
let agent = Agent::new("agent.acl").await?;
let options = SessionOptions::new()
.with_tenant_id("tenant-example")
.with_principal("principal-example")
.with_agent_template_id("agent-template-example")
.with_correlation_id("trace-example")
.with_file_session_store("./.a3s/sessions");
let session = agent
.session_builder("/path/to/project")
.options(options)
.build()
.await?;
println!("tenant: {:?}", session.tenant_id());
println!("principal: {:?}", session.principal());
println!("template: {:?}", session.agent_template_id());
println!("correlation: {:?}", session.correlation_id());
session.close().await;
agent.close().await;
Ok(())
}

Budget / cost guard

A budget guard lets the host gate every LLM call against a cost or token budget. The framework calls your guard before each LLM request and after it returns. The guard is policy you own; the framework only enforces the decision you hand back.

Rust
Node.js
Python
Go

The native guard decision shape is equivalent across all four SDKs:

Return valueEffect
None / null / { decision: 'allow' }Proceed with the LLM call.
{ decision: 'soft', resource, consumed, limit, message? }Emits BudgetThresholdHit (kind soft) and proceeds.
{ decision: 'deny', resource, reason }Aborts the LLM call. Python raises RuntimeError("Budget exhausted..."); Node rejects with "Budget exhausted...".

Robustness is intentional but SDK-specific: a missing guard method is treated as the permissive default. Python callback errors fall back to Allow. Node callbacks must not throw. Go errors, timeouts, and malformed check* returns fail closed as deny.

Cluster event vocabulary

The host emits cluster-level decisions as structured AgentEvent variants through its hook executor. In-session hooks subscribe to them uniformly — the same way they observe any other event — so policy authored at the host shows up to the agent's own hooks without special casing.

The cluster vocabulary is:

  • BudgetThresholdHit { resource, kind, consumed, limit, message? } — a budget guard returned a soft decision (or the host crossed a threshold it tracks itself). kind distinguishes soft warnings from harder limits.
  • PassivationRequested { reason, deadline_ms? } — the host is asking the session to reach a safe, persistable state so it can be evicted from this node. deadline_ms, when present, is the grace window before forced eviction.
  • PeerInvocation { from_session_id, from_tenant_id?, correlation_id? } — another session invoked this one. The labels let the receiver attribute the call back to its origin tenant and correlation chain.

These are observed through the same verified hook API your in-session hooks already use — session.registerHook in Node, session.register_hook in Python (see Hooks). Treat the three variants above as the documented contract; the host is responsible for emitting them via its hook executor.

Deterministic IDs and time (replay)

A cluster that wants bit-identical replay of a run on a different node must remove the two sources of nondeterminism in a normal run: random IDs and the wall clock. The Rust core models both behind a HostEnv { id_generator, clock }. The default pairs a UUID generator with the system clock; replay tooling swaps in a SequentialIdGenerator and a FixedClock so that re-executing the same inputs produces the same IDs and timestamps, and therefore the same output, on any node.

All four SDKs expose the same deterministic configuration. Set both the ID prefix and fixed timestamp, and recreate the configuration from the same values at the start of each replay.

Rust
Node.js
Python
Go
Rust
use std::sync::Arc;
use a3s_code_core::{
host_env::{FixedClock, HostEnv, SequentialIdGenerator},
Agent, SessionOptions,
};
#[tokio::main]
async fn main() -> a3s_code_core::Result<()> {
let agent = Agent::new("agent.acl").await?;
let host_env = Arc::new(HostEnv::new(
Arc::new(SequentialIdGenerator::new("replay")),
Arc::new(FixedClock::new(1_700_000_000_000)),
));
let session = agent
.session_builder(".")
.options(SessionOptions::new().with_host_env(host_env))
.build()
.await?;
println!("{}", session.session_id());
session.close().await;
agent.close().await;
Ok(())
}

Loop checkpoints and run resumption

With a sessionStore / session_store configured, the agent loop persists a checkpoint after each completed tool round, keyed by run id. Any node that shares the same store can rehydrate the run and continue it.

Rust
Node.js
Python
Go
Rust
use a3s_code_core::{Agent, SessionOptions};
#[tokio::main]
async fn main() -> a3s_code_core::Result<()> {
let agent = Agent::new("agent.acl").await?;
let session = agent
.session_builder(".")
.options(
SessionOptions::new()
.with_file_session_store("./.a3s/sessions")
.with_session_id("session-from-node-a"),
)
.build()
.await?;
let result = session.resume_run("run-id-from-node-a").await?;
println!("{}", result.text);
session.close().await;
agent.close().await;
Ok(())
}

A new run id is allocated for the resumed work — the original run is left intact in the store. Two error paths are worth handling:

  • resume_run requires a session_store — no store was configured; fall back to a fresh session.
  • no loop checkpoint found for run 'X' — the run never reached its first checkpoint, or it was pruned; retry later or treat the run as lost.

Because checkpoints are taken only between tool rounds, never mid-tool, a resumed run never replays a half-executed tool. See Persistence for store details.

Retention caps for long-running sessions

A session that runs for hours or days accumulates state in four in-memory stores: run records, per-run event buffers, trace events, and terminal subagent task snapshots. Left unbounded, these grow with session age — fine for short-lived sessions, a real leak for long-lived ones.

SessionRetentionLimits caps each store. Omitted fields keep the framework's finite defaults; set unbounded: true only when unlimited retention is intentional. Eviction is strict FIFO, and running subagent tasks are never dropped — only terminal snapshots are evicted.

Use retentionLimits in Node, opts.retention_limits in Python, or SessionOptions.RetentionLimits in Go. Rust hosts use SessionOptions::with_retention_limits(...). See Limits.


See also: Multi-machine · Persistence · Limits · Hooks