Skip to main content

AgentLoop

Struct AgentLoop 

Source
pub struct AgentLoop<M: Model> {
Show 18 fields pub model: M, pub tools: ToolRegistry, pub guides: Vec<Arc<dyn Guide>>, pub sensors: Vec<Arc<dyn Sensor>>, pub hooks: HookBus, pub compactor: Arc<dyn Compactor>, pub tool_timeout: Option<Duration>, pub response_format: ResponseFormat, pub streaming: bool, pub recall: Option<Arc<dyn RecallStore>>, pub recall_auto_inject: bool, pub learning: Option<LearningConfig>, pub stuck: StuckPolicy, pub compaction: CompactPolicy, pub tool_results: ToolResultPolicy, pub acceptance: Vec<Arc<dyn Acceptance>>, pub acceptance_retries: u32, pub system: Vec<Block>,
}
Expand description

The agent loop.

Fields§

§model: M§tools: ToolRegistry§guides: Vec<Arc<dyn Guide>>§sensors: Vec<Arc<dyn Sensor>>§hooks: HookBus§compactor: Arc<dyn Compactor>§tool_timeout: Option<Duration>

A deadline on each individual tool call. One hung call — a network tool on a dead endpoint, a shell command waiting on stdin — otherwise takes the whole run down with it, and the host’s only recourse is a run-level timeout that throws away every turn of finished work (measured on the completion benchmark: a run that had already done the job was billed as a 0-token timeout). A per-call deadline converts the hang into an error result the model sees and can route around. None disables. The default is generous — 120s covers a slow build — because a false deadline on a legitimately long tool is worse than a late one.

§response_format: ResponseFormat

Default response format applied to every run unless overridden by run_typed. See ResponseFormat.

§streaming: bool

When true, the loop drives each model turn via Model::stream() instead of complete(), firing Event::ModelTokenDelta for each text fragment. Tool-call deltas are still assembled inside the loop; only the terminal ModelOutput shape is observable downstream.

§recall: Option<Arc<dyn RecallStore>>

Optional cross-session recall store. When set, the loop captures every turn and the session_search tool is registered. See with_recall.

§recall_auto_inject: bool

When true (and recall is set), a RecallGuide auto-injects top-k past context at session start.

§learning: Option<LearningConfig>§stuck: StuckPolicy

Loop-detection policy. Enabled by default — see StuckPolicy.

§compaction: CompactPolicy

Context-compaction hysteresis. See CompactPolicy.

§tool_results: ToolResultPolicy

Ceiling on a single tool result. See ToolResultPolicy.

§acceptance: Vec<Arc<dyn Acceptance>>

Conditions the run must satisfy before the loop reports success. The model stopping is evidence it believes it is finished; these say whether it is. See acceptance.

§acceptance_retries: u32

How many times a failed acceptance is handed back to the model before the loop gives up and reports what there is. One is usually enough: a model that ignores the first correction rarely takes the second.

§system: Vec<Block>

System instruction injected into every run’s Context.system (unless the built context already carries its own). Set via with_system.

Implementations§

Source§

impl AgentLoop<DynModel>

Source

pub fn boxed(model: Arc<dyn Model>) -> Self

Build a loop from a boxed model — what every model factory hands back (ApiKind::build, a router, anything stored behind a trait object).

Arc<dyn Model> deliberately does not implement Model (see DynModel for why), so AgentLoop::new cannot take one. Without this constructor every caller writes the wrapper themselves, and the first thing a new user meets is a trait-bound error naming a type they have never heard of.

let model = ApiKind::OpenAI.build(base_url, model_id, key);
let agent = AgentLoop::boxed(model).with_tool(Arc::new(ReadFile));
Source§

impl<M: Model> AgentLoop<M>

Source

pub fn new(model: M) -> Self

Source

pub fn with_system(self, text: impl Into<String>) -> Self

Set a system instruction applied to every run (into Context.system) — e.g. “answer only via the governed tools; never claim you can’t access data; never invent numbers”. This is the first-class seam for a system prompt; small local models in particular need it to reliably call tools instead of refusing or hallucinating.

Source

pub fn with_stuck_policy(self, policy: StuckPolicy) -> Self

Override the loop-detection policy (thresholds, or disable entirely).

Source

pub fn with_tool_result_policy(self, policy: ToolResultPolicy) -> Self

Override the compaction hysteresis policy. See CompactPolicy. Set the ceiling on a single tool result. See ToolResultPolicy.

Source

pub fn with_compact_policy(self, policy: CompactPolicy) -> Self

Source

pub fn with_streaming(self, enable: bool) -> Self

Opt in to streaming the model’s terminal turn token-by-token via Model::stream(). Hooks subscribed to Event::ModelTokenDelta see each fragment as it arrives; the rest of the loop is unchanged.

Source

pub fn with_acceptance(self, a: Arc<dyn Acceptance>) -> Self

Add a condition the run must satisfy before it can report success.

Source

pub fn with_acceptance_set(self, set: Vec<Arc<dyn Acceptance>>) -> Self

Replace the acceptance set outright (including the default).

Source

pub fn with_acceptance_retries(self, n: u32) -> Self

Source

pub fn with_tool_timeout(self, t: Option<Duration>) -> Self

Source

pub fn with_compactor(self, c: Arc<dyn Compactor>) -> Self

Source

pub fn with_tool(self, t: Arc<dyn Tool>) -> Self

Source

pub fn with_guide(self, g: Arc<dyn Guide>) -> Self

Source

pub fn with_sensor(self, s: Arc<dyn Sensor>) -> Self

Source

pub fn with_hook(self, h: Arc<dyn Hook>) -> Self

Source

pub fn with_macro_hooks(self) -> Self

Pull in every #[hook]-registered hook.

Source

pub fn with_recall(self, store: Arc<dyn RecallStore>) -> Self

Enable cross-session recall: capture every turn into store and register the session_search tool. Owner + session id are read from world.profile.extra["recall_owner"|"recall_session"] at run time.

Source

pub fn with_recall_ingest(self, store: Arc<dyn RecallStore>) -> Self

Capture every turn into store without registering a search tool.

Ingest and retrieval are separate concerns that [with_recall] happens to bundle, and the bundling is a trap: capture only ever happens when self.recall is set, so a host that wants a different search tool — one scoped per tenant, or one that copes with a language the backend’s index does not — has no way to get the writes without also getting session_search, and ends up offering the model two overlapping tools to choose between.

Use this, then register whichever retrieval tool suits the deployment.

Source

pub fn auto_inject(self) -> Self

After with_recall, also auto-inject top-k relevant past context at session start (off by default — tool-only is prompt-cache friendly).

Source

pub fn with_learning_loop(self, cfg: LearningConfig) -> Self

Enable the self-evolving learning loop: after a session that made >= cfg.nudge_interval tool calls, fork a review subagent (white-listed to cfg.tools) to update skills + memory from the transcript. Best-effort.

Source

pub fn with_response_format(self, fmt: ResponseFormat) -> Self

Set the default response format for all runs through this loop. See ResponseFormat. For typed deserialisation, prefer run_typed::<T>().

Source

pub fn with_response_schema( self, name: impl Into<String>, schema: Value, ) -> Self

Shortcut for with_response_format(ResponseFormat::JsonSchema { name, schema }). Accepts a raw serde_json::Value so callers can hand-roll the schema or pull it from schemars::schema_for!(T).

Source

pub async fn run( &self, task: Task, world: &mut World, ) -> Result<Outcome, HarnessError>

Source

pub async fn run_receipted( &self, task: Task, world: &mut World, now_ms: i64, ) -> Result<(Outcome, Receipt), HarnessError>

Run, and hand back the evidence alongside the result.

The Receipt is built here rather than by the caller because the loop already knows the two things a caller would otherwise have to restate — the task and the model — and restating them is how a receipt ends up describing a different run than the one that happened. now_ms stays a parameter: this crate does not read the clock, so a receipt is reproducible in a test.

Source

pub async fn run_goal( &self, goal: &mut Goal, store: &GoalStore, world: &mut World, now_ms: i64, ) -> Result<Option<(Outcome, Receipt)>, HarnessError>

Advance a Goal by one phase, recording the result durably.

Returns None when every phase is done — so a resume loop is while let Some(..) = loop_.run_goal(..).await?.

The phase is marked Running and saved before the model starts, so a process that dies mid-run leaves a goal that says where it was rather than one that looks untouched. It is saved again afterwards on both paths. That second save on the failure path is the whole reason this method exists: written out by hand at each call site it is four lines, and the failure branch is the one that gets forgotten — which loses exactly the run you most wanted a record of.

Source

pub async fn run_with_max_iters( &self, task: Task, world: &mut World, max_iters: u32, ) -> Result<Outcome, HarnessError>

Source

pub async fn run_typed<T>( &self, task: Task, world: &mut World, ) -> Result<T, HarnessError>
where T: DeserializeOwned + JsonSchema + 'static,

Run the agent and deserialise the terminal reply into T.

The schema for T is derived via schemars::schema_for!(T) and installed as ResponseFormat::JsonSchema for this run only — any pre-existing self.response_format is ignored. On success the returned T is parsed from Outcome::Done.text (or, on budget exhaustion, from Outcome::BudgetExhausted.last_text).

Errors:

  • HarnessError::Other if the model returns no text at all
  • HarnessError::Other if serde_json::from_str::<T>(text) fails — the original text is included in the message for debugging.
Source

pub async fn run_typed_with_max_iters<T>( &self, task: Task, world: &mut World, max_iters: u32, ) -> Result<T, HarnessError>
where T: DeserializeOwned + JsonSchema + 'static,

Like run_typed but with explicit max_iters.

Source

pub async fn run_with_response_format( &self, task: Task, world: &mut World, max_iters: u32, fmt: ResponseFormat, ) -> Result<Outcome, HarnessError>

Run with a one-off ResponseFormat override (doesn’t touch self).

Source

pub async fn run_with_seed_history( &self, task: Task, seed: Vec<Turn>, world: &mut World, max_iters: u32, ) -> Result<Outcome, HarnessError>

Like run_with_max_iters but seeds ctx.history with seed before the current user task is appended. Use this for multi-turn REPLs so prior conversation lives in ctx.history (where the Compactor can see it) instead of being concatenated into task.description (where it previously bypassed compaction entirely — see audit #2).

Source

pub async fn run_with_seed_and_metadata( &self, task: Task, seed: Vec<Turn>, metadata: BTreeMap<String, Value>, world: &mut World, max_iters: u32, ) -> Result<Outcome, HarnessError>

Like run_with_seed_history but also seeds ctx.metadata with per-request key/values. Hooks and a ModelRouter read this map — e.g. audit.actor / audit.session for the audit trail, or router.keep_local to pin a request to the local model. This is the entry point a serving layer uses to pass caller identity and routing flags into a single, shared, reused loop.

Source

pub fn session(&self) -> Session<'_, M>

Start a persistent multi-turn Session. Each turn re-runs the loop against the accumulated history with a stable prefix (system + name-sorted tool schemas), so a provider’s prefix cache (e.g. DeepSeek’s, ~10% price on cache-hit tokens) hits across turns instead of paying full price to re-read the same bytes every round. For maximum hit rate, keep your guides’ output stable (put per-turn volatile context in the message, not a recomputed system guide).

Auto Trait Implementations§

§

impl<M> !RefUnwindSafe for AgentLoop<M>

§

impl<M> !UnwindSafe for AgentLoop<M>

§

impl<M> Freeze for AgentLoop<M>
where M: Freeze,

§

impl<M> Send for AgentLoop<M>

§

impl<M> Sync for AgentLoop<M>

§

impl<M> Unpin for AgentLoop<M>
where M: Unpin,

§

impl<M> UnsafeUnpin for AgentLoop<M>
where M: UnsafeUnpin,

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> PolicyExt for T
where T: ?Sized,

Source§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow only if self and other return Action::Follow. Read more
Source§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow if either self or other returns Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more