Core ConceptsReference
The inner agentic loop
The while(tool_use) primitive. How a turn runs, and the paths that end it.
On this page
The primitive
AgenticLoop lives in core/agent/loop/agent_loop.py. It is the engine behind every task run, and the shape is deliberately simple: keep going while the model asks for tools.
while stop_reason == "tool_use":
round-entry guards # round / time / session / cost budget
context-overflow check # compact or prune if needed
response = call_llm(messages, tools)
run tool calls -> append results -> checkpointResponsibilities are split into sibling modules in the same package: the physical-turn order remains visible in agent_loop.py, while _phases.py owns six functions for input preparation, model-call preparation, provider retry decisions, tool processing, observation/history compaction, and terminal assembly; system-prompt and context delegation in _context.py, the result model and context-exhaustion handling in models.py, model switching in _model_switching.py, durable child mailbox admission in _collaboration_mailbox.py. The old monolithic core/agent/loop.py no longer exists.
The turn cycle
Every round follows the same order:
- Round-entry guards: round count, time budget, session budget, cost budget.
- Context-overflow check: compact or prune when thresholds are crossed. Details in Context assembly.
- Call the LLM.
- Execute the tools the model requested.
- Append the assistant message and tool_results, checkpoint them, then enter the next round.
Immediately before each LLM sample, an immutable StepSnapshot pins the model route, tool-plan generation, budgets, cancellation handle, and trace correlation. The response's tool batch uses that same snapshot. A mutable TurnState owns one physical turn's messages, completed rounds, retries, plan hint, and terminal reason, so retries at the same round still receive monotone sampling-step IDs.
Mutation, communication, and administrative tools first strictly checkpoint an independent logical operation ID and sampling step with the assistant tool call, then record its receipt in the project sessions.db. Restart recovers only through that anchor; the provider call ID merely pairs tool use with tool result. A committed receipt replays the PostToolUse-final result; an unfinished receipt stops with effect_outcome_uncertain. Restart closes that anchored call from the receipt before another model request. This is duplicate suppression, not an external exactly-once guarantee. After checking the external sink, operators resolve older uncertain steps through geode session effects and resolve-effect; both applied and not-applied remain as evidence. Personal arguments are not hashed, so same-ID re-admission without the checkpoint anchor is rejected.
Round-entry guards
| Guard | Condition | Behaviour |
|---|---|---|
| Round limit | max_rounds > 0 (0 means unlimited) | Terminates with max_rounds |
| Time budget | time_budget_s > 0, wall-clock | Terminates with time_budget_expired |
| Session budget | Default session cap of two hours (core/agent/budget.py) | Fires HANDOFF_TRIGGERED once near the threshold, hard stop on expiry |
| Cost budget | cost_budget > 0 | Warns once at 80%, terminates with cost_budget_exceeded when reached |
Termination paths
Every run ends with exactly one AgenticResult.termination_reason. The source of truth is core/agent/loop/models.py.
| termination_reason | Meaning |
|---|---|
natural | The model finished its answer with no further tool calls |
forced_text | Wrap-up forced a text response (adaptive compute: clamped max_tokens, thinking off) |
max_rounds | Round limit reached |
time_budget_expired | Wall-clock budget spent |
cost_budget_exceeded | Session cost reached the budget |
context_exhausted | Context still critical after compaction and pruning |
llm_error | Unrecoverable LLM call failure |
model_action_required | The model signalled that external action is required |
user_clarification_needed | Retained for historical termination records. Text length alone no longer stops execution. |
model_refusal | The model declined on safety grounds (next section) |
input_blocked | Input was blocked by UserPromptSubmit |
billing_error | Fatal billing or quota error |
user_cancelled | Cancelled by the user |
convergence_detected | Repetition without progress detected |
The default is unknown; it never appears on a healthy path.
model_refusal: refusals as a first-class ending
Fable 5's safety classifiers decline a request not with an HTTP error but with HTTP 200 carrying stop_reason: "refusal", often with empty content. Treat that like a normal response and an empty answer silently reaches the user.
GEODE handles it at two points: normalize_anthropic in the Anthropic provider preserves the response's stop_details, and the loop maps it to termination_reason="model_refusal" with an honest message that includes stop_details.category. The same path also covers Opus 4.7 and 4.8.
Hooks fired
The loop fires lifecycle events at every meaningful boundary: TURN_COMPLETED at round end, LLM_CALL_STARTED / LLM_CALL_ENDED / LLM_CALL_FAILED / LLM_CALL_RETRIED around LLM calls, TOOL_EXEC_STARTED / TOOL_EXEC_ENDED / TOOL_EXEC_FAILED around tool execution, TOOL_APPROVAL_REQUESTED / GRANTED / DENIED at the approval gate, and CONTEXT_CRITICAL plus CONTEXT_OVERFLOW_ACTION for context pressure. The full list and registration are in Hooks and observability.
Why a thin loop
The loop's thinness is deliberate. It is the most-tested, least-changed code in the system. New behaviour does not go inside the loop; it goes into a tool, a hook, or a guard. That keeps the core execution path predictable and testable.
Next
- Context assembly. Overflow handling and token budgets in detail.
- Sub-agent orchestration. How the loop delegates work.
- The two loops. Where this loop sits in the bigger picture.
Moved from the GEODE repository’s documentation site. Source commit de077a4 · View the source on GitHub