LLM Backend Design¶
This page describes the internals of the solver — the part of the framework that turns a rendered prompt into a parsable solution. The LLM Integration user-guide page covers the same loop from a configuration point of view (which knobs exist and what they do); this page covers the code structure and the reasoning behind it, for people changing it.
The backend is built on LangChain (model abstraction and tools) and LangGraph (the solve loop as an explicit state machine).
Three layers¶
The solver is deliberately split into three objects with shrinking scope:
| Object | Lifetime | Responsibility |
|---|---|---|
SolverConfig |
one per run | validated, frozen configuration |
Solver |
one per worker thread | holds the model, prompts, tools; builds a workflow per solve |
Workflow |
one per solve() call |
the compiled LangGraph state machine for a single attempt |
Solver.from_config constructs a solver from the config; solver.solve(session,
data) builds a fresh Workflow, compiles it, and streams it to completion. A
new workflow per solve keeps each attempt's state fully isolated — important
because solver workers run concurrently and runs_per_data may launch several
attempts on the same item.
The graph¶
The workflow is a StateGraph over a small typed State:
MessagesState provides the running messages list; performed_steps counts
"think" turns against max_steps; result holds the final
Result once a terminal node sets it.
The nodes and the transitions between them:
START
│
▼
think ◄───────────────────────────────────────────────┐
│ │
▼ │
route ── (tool calls, context OK) ─────────────► use_tools
│ │ ▲
│ └ (tool calls, context full) ─► compact_context ─┘ (straight to tools)
│
│
├── (plain answer) ─────────────────► adapt_solution ──► END
│ ▲
└── (performed_steps > max) ─► force_finish ─┘
think and the edge think → route are fixed; everything else is decided at
runtime by route, which returns a LangGraph Command(goto=...) rather than
relying on static conditional edges. Centralizing the branching in one node keeps
the policy ("when do we use tools / compact / finish?") in a single readable
place.
Node responsibilities¶
think— calls the model on the current history, appends the reply, and incrementsperformed_steps.route— the policy node. In order: if the step budget is exceeded, resolve any dangling tool calls and go toforce_finish; if the last message has no tool calls, go toadapt_solution; if context occupancy is at/over the threshold, go tocompact_context; otherwise go touse_tools.use_tools— a LangGraphToolNodethat executes the pending tool calls. Its outputs are post-processed before re-enteringthink(see Tool output formatting).compact_context— summarizes the middle of the conversation and rewrites the message list (see Context compaction).force_finish— the model has spent its step budget; ask it for a final answer with tool calls disabled, then fall through toadapt_solution. Both the injected feedbackHumanMessageand the model's reply are returned in the node update so the solver can record them as separate entries in the session log.adapt_solution— runs theSolutionAdapteron the last message, retrying with the reformat prompt on failure; setsresult.
Prompts¶
The framework-injected prompts are plain Markdown files shipped with the package
under ape/llm/prompts/ (system.md, compact-context.md, force-finish.md,
summarize-error.md). Their lifetimes differ:
system.mdis read once inSolver.__init__and cached on the solver (joined with the benchmark'suser_system_promptif any — the base is always prepended, never replaced).compact-context.md,force-finish.md,summarize-error.mdare read inWorkflow.__init__, i.e. afresh on everysolve()call.
Resolving the model: _invoke_model¶
Every model call across all nodes goes through Workflow._invoke_model, the one
place that owns retry behaviour. It walks rate_limit_backoff_seconds, sleeping
the listed number of seconds between attempts; the length of that tuple is the
number of attempts, so the schedule and the retry count are configured by a
single value. If every attempt fails it raises LLMInvokeErr, which the solver
turns into an INTERNAL_ERROR result. The cancellation Event is checked
between attempts — before each invoke and again before each backoff sleep
— but the time.sleep(backoff) itself is uninterruptable, so a shutdown that
arrives mid-backoff will still wait out the remaining sleep (up to
max(rate_limit_backoff_seconds) seconds, 32 s by default). Keep that in mind
if you raise the backoff schedule.
A subtlety that recurs: several nodes invoke the model with
self._llm.bind(tool_choice="none"). force_finish, adapt_solution, and
compact_context all want plain text, and some providers reject a message
ordering or demand a tool response when tools are bound — forbidding tool calls
for those turns avoids provider-specific 400s.
Tools¶
Tools are bound once, right after construction, in Workflow.add_tools: the
unbound BaseChatModel becomes a tools-bound Runnable via bind_tools, and a
ToolNode is added as the use_tools node. The model field _llm is typed as a
Runnable precisely because it starts as a chat model and may become a bound
runnable.
Tool output formatting¶
use_tools wraps each tool call with _format_tool_output, which does two
things to the raw ToolMessage:
- Normalizes the JSON the tool returns into a compact, model-friendly
STATUS: …block (the built-in tools return a dict with astatuskey plus payload fields). - Summarizes long errors. If the tool reports an error whose text is at
least
error_summarization_thresholdcharacters, the workflow asks an LLM to summarize it — using the helper model if one is configured, falling back to the main model — and records that it summarized (and the original length) in the message'sadditional_kwargs. If the helper itself fails, or returns something no shorter than the original, it falls back to plain truncation. The principle is best-effort: a flaky helper or a giant stack trace must never kill the run, only degrade the message.
Context compaction¶
The workflow tracks how full the context window is and compacts rather than
truncates. Occupancy is estimated by _context_window_occupation with the
o200k_base tokenizer (a provider-neutral approximation, since
BaseChatModel.get_num_tokens is unreliable across providers), counting both the
message history and the JSON tool schemas. WorkflowParams resolves the window
size from the model's LangChain profile when it isn't set explicitly, defaulting
to 256k tokens.
When route sees occupancy at/above context_compaction_threshold with tool
calls pending, compact_context asks the LLM (with tool_choice="none") to
summarize messages[1:-1] — the original instruction plus the middle of the
conversation, dropping only the system prompt (index 0) and the last message
(the pending tool-call AIMessage). The summary then replaces the middle,
so the rebuilt list is [system, instruction, summary, last]: the instruction
both stays verbatim and informs the summary's content. It then routes
straight to use_tools with an Overwrite of the messages, deliberately
bypassing route: compaction is only ever reached with tool calls pending, so
going back through route could re-check occupancy and loop without advancing
performed_steps if the threshold were set very low.
Terminating: force_finish and adapt_solution¶
There are two ways to leave the loop, and both converge on adapt_solution:
- The model stops calling tools on its own →
routesends it toadapt_solution. - The model exhausts
max_steps→force_finishinjects a "give me your final answer now" prompt (tools disabled) and edges intoadapt_solution.
adapt_solution then loops up to solution_adaption_attempts times: it feeds the
last message to the adapter, and on a parse exception it appends the
reformat_prompt and re-invokes the model (again with tool_choice="none", so
the retry is always plain text). Two early exits short-circuit this: if the model
output contains the literal I_GIVE_UP the result is Err(GAVE_UP); if all
attempts fail to parse it is Err(PARSING_FAILED). Otherwise it is
Ok(solution).
The Session: how the solver talks to the rest of the framework¶
The solver does not write to the dashboard or the logs directly. Each
solve() is handed a Session — created fresh by the
orchestrator — that is the only channel out:
- Status —
session.report(state, session_length)pushes aSolverState(SOLVING,AWAITING_TOOL,AWAITING_CHECK,IDLE) to the worker dashboard. - Transcript —
session.add(kind, turn, fields)buffers oneEntryKindentry. The full set isPROMPT,LLM_RESPONSE,TOOL_CALL,TOOL_OUTPUT,FEEDBACK,COMPACT_CONTEXT,CHECKER_RESULTS,FAILURE,SESSION_END.session.close(reason, turn)appends the terminalSESSION_ENDentry and flushes the whole transcript exactly once at the end. - Header summary —
session.data_summaryis a one-line string written into the session-log header for each session. It defaults tostr(data.data_id), but solvers are encouraged to overwrite it with something more descriptive (the built-inSolveruses a short rendering of the data item) before callingclose. - Cancellation —
session.cancelled()exposes theSolverOrchestrator's cancellationEvent(shared across every session it spawns, but distinct from theCheckerOrchestrator's own event).
Solver.solve consumes the compiled graph with
compiled.stream(..., stream_mode="updates") and translates each node update
into the appropriate session entries — this is where the workflow's internal
steps become the human-readable sessions.log and the machine-readable
sessions.jsonl (see Outputs & Results). Keeping
logging in the streaming loop rather than inside the nodes means the workflow
stays a pure state machine and the session stays the single sink for everything
observable about a solve.
Error taxonomy¶
The internal failure modes map onto the ErrKind values the solver returns, and
through OutputBatch onto the labels you see in the
visualizer:
| Situation | Outcome |
|---|---|
| Adapter parses the answer | Ok(solution) → checker runs |
Output contains I_GIVE_UP |
Err(GAVE_UP) |
| Adapter fails every attempt | Err(PARSING_FAILED) |
| LLM call exhausts retries, or an unexpected exception | Err(INTERNAL_ERROR) |
| Run is shutting down | InterruptedError, session closed as cancelled |
Two internal exceptions support this: LLMInvokeErr (retries exhausted) and
CorruptedContextErr (the message history violated an invariant a node relies
on, e.g. the last message wasn't an AIMessage); both surface as
INTERNAL_ERROR.