Core ConceptsReference

The inner agentic loop

The while(tool_use) primitive. How a turn runs, and the paths that end it.

On this page
  1. The primitive
  2. The turn cycle
  3. Round-entry guards
  4. Termination paths
  5. model_refusal: refusals as a first-class ending
  6. Hooks fired
  7. Why a thin loop
  8. Next

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 -> checkpoint

Responsibilities 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:

  1. Round-entry guards: round count, time budget, session budget, cost budget.
  2. Context-overflow check: compact or prune when thresholds are crossed. Details in Context assembly.
  3. Call the LLM.
  4. Execute the tools the model requested.
  5. 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.

AgenticLoop turn cycle: round-entry guards, context-overflow check, LLM call, tool execution, and the early-termination paths
One turn through the loop, and the paths that end it. A blocked guard skips the round and records its termination reason.

Round-entry guards

GuardConditionBehaviour
Round limitmax_rounds > 0 (0 means unlimited)Terminates with max_rounds
Time budgettime_budget_s > 0, wall-clockTerminates with time_budget_expired
Session budgetDefault session cap of two hours (core/agent/budget.py)Fires HANDOFF_TRIGGERED once near the threshold, hard stop on expiry
Cost budgetcost_budget > 0Warns 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_reasonMeaning
naturalThe model finished its answer with no further tool calls
forced_textWrap-up forced a text response (adaptive compute: clamped max_tokens, thinking off)
max_roundsRound limit reached
time_budget_expiredWall-clock budget spent
cost_budget_exceededSession cost reached the budget
context_exhaustedContext still critical after compaction and pruning
llm_errorUnrecoverable LLM call failure
model_action_requiredThe model signalled that external action is required
user_clarification_neededRetained for historical termination records. Text length alone no longer stops execution.
model_refusalThe model declined on safety grounds (next section)
input_blockedInput was blocked by UserPromptSubmit
billing_errorFatal billing or quota error
user_cancelledCancelled by the user
convergence_detectedRepetition 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

Moved from the GEODE repository’s documentation site. Source commit de077a4 · View the source on GitHub