pub struct AgentLoop<M: Model> {Show 20 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>,
pub model_roles: HashMap<String, Arc<dyn Model>>,
pub compactor_custom: bool,
}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: ResponseFormatDefault response format applied to every run unless overridden by
run_typed. See ResponseFormat.
streaming: boolWhen 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: boolWhen true (and recall is set), a RecallGuide auto-injects top-k
past context at session start.
learning: Option<LearningConfig>§stuck: StuckPolicyLoop-detection policy. Enabled by default — see StuckPolicy.
compaction: CompactPolicyContext-compaction hysteresis. See CompactPolicy.
tool_results: ToolResultPolicyCeiling 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: u32How 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.
model_roles: HashMap<String, Arc<dyn Model>>Named auxiliary models for side tasks — compaction, memory synthesis,
judging, subagents — so the main conversation stays on one model (and
keeps its provider prompt-cache prefix intact) while cheap or specialist
work goes elsewhere. Populated via with_model_role,
read via model_for. An unregistered role means
“use the main model” — components fall back rather than fail.
compactor_custom: boolTrue once the host installed its own compactor via
with_compactor. Guards the "compactor"
model-role convenience from overwriting an explicit choice.
Implementations§
Source§impl AgentLoop<DynModel>
impl AgentLoop<DynModel>
Sourcepub fn boxed(model: Arc<dyn Model>) -> Self
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>
impl<M: Model> AgentLoop<M>
pub fn new(model: M) -> Self
Sourcepub fn with_model_role(
self,
role: impl Into<String>,
model: Arc<dyn Model>,
) -> Self
pub fn with_model_role( self, role: impl Into<String>, model: Arc<dyn Model>, ) -> Self
Register an auxiliary model under a role name — the loop’s seam for “side tasks don’t have to run on the main model”.
The main conversation stays pinned to one model, which keeps its
provider prompt-cache prefix byte-stable; compaction summaries, memory
synthesis, judging, and subagents can go to a cheaper or specialist
model instead. Components read roles via model_for
and fall back to the main model when a role is unregistered.
One role is wired automatically: registering "compactor" upgrades the
default structural compactor to a [ModelBackedCompactor] on that
model — unless the host already installed its own via
with_compactor, which always wins regardless
of call order.
let agent = AgentLoop::boxed(main)
.with_model_role("compactor", cheap.clone())
.with_model_role("judge", strong);Sourcepub fn model_for(&self, role: &str) -> Option<Arc<dyn Model>>
pub fn model_for(&self, role: &str) -> Option<Arc<dyn Model>>
Look up an auxiliary model by role. None means “no model registered
for this role — use the main model”; callers fall back rather than fail,
so wiring stays optional everywhere.
Sourcepub fn with_system(self, text: impl Into<String>) -> Self
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.
Sourcepub fn with_stuck_policy(self, policy: StuckPolicy) -> Self
pub fn with_stuck_policy(self, policy: StuckPolicy) -> Self
Override the loop-detection policy (thresholds, or disable entirely).
Sourcepub fn with_tool_result_policy(self, policy: ToolResultPolicy) -> Self
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.
pub fn with_compact_policy(self, policy: CompactPolicy) -> Self
Sourcepub fn with_streaming(self, enable: bool) -> Self
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.
Sourcepub fn with_acceptance(self, a: Arc<dyn Acceptance>) -> Self
pub fn with_acceptance(self, a: Arc<dyn Acceptance>) -> Self
Add a condition the run must satisfy before it can report success.
Sourcepub fn with_acceptance_set(self, set: Vec<Arc<dyn Acceptance>>) -> Self
pub fn with_acceptance_set(self, set: Vec<Arc<dyn Acceptance>>) -> Self
Replace the acceptance set outright (including the default).
pub fn with_acceptance_retries(self, n: u32) -> Self
pub fn with_tool_timeout(self, t: Option<Duration>) -> Self
pub fn with_compactor(self, c: Arc<dyn Compactor>) -> Self
pub fn with_tool(self, t: Arc<dyn Tool>) -> Self
pub fn with_guide(self, g: Arc<dyn Guide>) -> Self
pub fn with_sensor(self, s: Arc<dyn Sensor>) -> Self
pub fn with_hook(self, h: Arc<dyn Hook>) -> Self
Sourcepub fn with_macro_hooks(self) -> Self
pub fn with_macro_hooks(self) -> Self
Pull in every #[hook]-registered hook.
Sourcepub fn with_recall(self, store: Arc<dyn RecallStore>) -> Self
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.
Sourcepub fn with_recall_ingest(self, store: Arc<dyn RecallStore>) -> Self
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.
Sourcepub fn auto_inject(self) -> Self
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).
Sourcepub fn with_learning_loop(self, cfg: LearningConfig) -> Self
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.
Sourcepub fn with_response_format(self, fmt: ResponseFormat) -> Self
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>().
Sourcepub fn with_response_schema(
self,
name: impl Into<String>,
schema: Value,
) -> Self
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).
pub async fn run( &self, task: Task, world: &mut World, ) -> Result<Outcome, HarnessError>
Sourcepub async fn run_receipted(
&self,
task: Task,
world: &mut World,
now_ms: i64,
) -> Result<(Outcome, Receipt), HarnessError>
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.
Sourcepub async fn run_goal(
&self,
goal: &mut Goal,
store: &GoalStore,
world: &mut World,
now_ms: i64,
) -> Result<Option<(Outcome, Receipt)>, HarnessError>
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.
pub async fn run_with_max_iters( &self, task: Task, world: &mut World, max_iters: u32, ) -> Result<Outcome, HarnessError>
Sourcepub async fn run_typed<T>(
&self,
task: Task,
world: &mut World,
) -> Result<T, HarnessError>where
T: DeserializeOwned + JsonSchema + 'static,
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::Otherif the model returns no text at allHarnessError::Otherifserde_json::from_str::<T>(text)fails — the original text is included in the message for debugging.
Sourcepub 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,
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.
Sourcepub async fn run_with_response_format(
&self,
task: Task,
world: &mut World,
max_iters: u32,
fmt: ResponseFormat,
) -> Result<Outcome, HarnessError>
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).
Sourcepub async fn run_with_seed_history(
&self,
task: Task,
seed: Vec<Turn>,
world: &mut World,
max_iters: u32,
) -> Result<Outcome, HarnessError>
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).
Sourcepub 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>
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.
Sourcepub fn session(&self) -> Session<'_, M>
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).