strategies — BaseStrategy and Seven Concrete Strategies
On this page
Each strategy knows how to arrange system instructions, context, memory, and the user query into a specific prompt shape.
Table of contents
BaseStrategy
class BaseStrategy(ABC):
STRATEGY: PromptStrategy # set by each subclass
def build(self, request, context_block, memory_block, guardrail_block, citation_map) -> BuiltPrompt: ...
@abstractmethod
def _build_system(self, request, guardrail_block) -> str: ...
@abstractmethod
def _build_user(self, request, context_block, memory_block) -> str: ...
build() — internal workflow
system = self._build_system(request, guardrail_block)user = self._build_user(request, context_block, memory_block)messages = self._assemble_messages(request, system, user)(built once, then rebuilt after optimization — see step 5)- Construct a
PromptOptimizer()with default settings (strategies do not forward any optimizer configuration from the request) and callopt.optimize(system, user, request)→(system, user, saved, notes). - Rebuild
messagesfrom the optimizedsystem/user(the pre-optimizationmessagesfrom step 3 is discarded/overwritten). - Estimate tokens via the local
_estimate_tokens()helper (samewords × 1.3heuristic as elsewhere). - Return a
BuiltPromptwithdocuments_included = len(request.documents)— note this is the raw requested document count, not the post-filter/dedup count;PromptBuilder.build()overwrites this field afterward with the accurate post-processing count (see builder.md).
_assemble_messages()
def _assemble_messages(self, request, system, user) -> List[Message]
Builds [Message("system", system)] + request.memory[-10:] + [Message("user", user)]. This means the last 10 raw Message objects from request.memory are injected directly as conversation turns in addition to whatever memory_block text was already woven into the system/user prompt by _build_system/_build_user — conversation history can appear twice in the final messages list (once as formatted text, once as native message turns), though system_prompt/user_prompt (the flat strings) only contain the formatted text version.
Shared helpers available to subclasses
_lang(request) -> Lang—resolve_lang(request.language)_tpl(request) -> StrategyTemplate—get_strategy_template(self.STRATEGY)_persona_line(request) -> str—persona_line(self._lang(request), request.user_profile)_language_line(request) -> str— returns""for empty/"en"/"english"language or when_lang(request) == Lang.AR; otherwise returnsf"Respond in {request.language}."(covers languages with no native template, e.g. French/German)
Thread safety
Each concrete strategy (SimpleStrategy(), etc.) is instantiated once at module import time and stored in STRATEGY_REGISTRY — the same instance is reused across every build() call system-wide. Since strategy instances hold no per-call mutable state (all data flows through method arguments), this singleton pattern is safe for concurrent read-only use, though a fresh PromptOptimizer() is constructed on every build() call rather than being a shared/cached instance.
The seven strategies
Each subclass implements only _build_system and _build_user; the assembly/optimization pipeline in BaseStrategy.build() is shared by all of them.
SimpleStrategy
STRATEGY = PromptStrategy.SIMPLE. Direct single-hop QA.
- System: intro line + persona line (if any) + language note (if any) + guardrail block (if any), joined by blank lines.
- User:
## Context(if any) →## Conversation History(if any) →## Question→## Answer(empty header, prompting the model to fill it in).
prompt = engine.build(query="What is RAG?", strategy="simple")
ChainOfThoughtStrategy
STRATEGY = PromptStrategy.CHAIN_OF_THOUGHT (enum value "cot"). Explicit reasoning before the final answer.
- System: intro +
tpl.get_extra(lang)(an extra "think step by step" instruction line) + persona + language note + guardrails. - User:
## Context→## Conversation History→## Question→## Reasoning Process(with a 4-point scaffold: what is being asked, what's relevant, any conflicts/gaps, most accurate answer) →## Final Answer.
prompt = engine.build(query="Why did the crisis happen?", strategy="cot")
MultiHopStrategy
STRATEGY = PromptStrategy.MULTI_HOP. Decompose → sub-answer → synthesize, for complex/multi-document questions.
- System: intro + extra instructions + persona + language note + guardrails.
- User:
## Context Documents→## Conversation History→## Main Question→## Analysis(scaffold block) →## Final Answer.
prompt = engine.build(
query="How are the causes of the 2008 crisis and the 1929 crash related?",
documents=docs,
strategy="multi_hop",
)
SelfConsistentStrategy
STRATEGY = PromptStrategy.SELF_CONSISTENT. Asks the model to generate multiple independent reasoning paths and converge on the most consistent answer — the convergence itself happens inside a single generation (via the ## Task scaffold instructing this), not by the library issuing multiple LLM calls and voting; there is no sampling-and-aggregation logic anywhere in this codebase.
- System: intro + language note + guardrails (no persona line — the only strategy that omits it).
- User:
## Context→## Question→## Task(scaffold) →## Final Answer. No## Conversation Historysection — memory is not surfaced in the user prompt for this strategy even ifmemory_blockis non-empty (it's simply never referenced in_build_user).
[!WARNING] Memory is silently dropped for
SelfConsistentStrategyandStepBackStrategyBoth strategies'_build_usermethods never read thememparameter passed in. If you rely on conversation history and selectself_consistentorstep_back, the formatted memory text will not appear inuser_prompt(though the raw last-10Messageobjects still get appended tomessagesvia_assemble_messages, per the note above).
StepBackStrategy
STRATEGY = PromptStrategy.STEP_BACK. Generates a higher-level abstract question first, then grounds the specific answer in it.
- System: intro + language note + guardrails.
- User:
## Context→## Specific Question→ scaffold block (tpl.block(lang, 'steps')) →## Final Answer. Memory also silently dropped (see warning above).
ReActStrategy
STRATEGY = PromptStrategy.REACT. Reasoning + Acting loop for agentic/tool-use tasks.
- System: intro + extra instructions + guardrails. No persona line and no language note appended — the only strategy that skips both.
- User:
## Available Info(context, if any) →## Available Tools(only ifrequest.extra.get("tools")is truthy — rendered via the module-level_format_tools()helper) →## Prior Conversation(memory, if any) →## Task(the query) → a bareThoughtlabel with no content after it (the model is expected to continue generating from there).
_format_tools(tools: List[Dict]) -> str renders each tool as - **{name}**: {description} plus, if the tool dict has a parameters key, an indented Parameters: {compact JSON} line.
prompt = engine.build(
query="Book a flight to Cairo for next Tuesday.",
prompt_type="agent",
extra={"tools": [{"name": "search_flights", "description": "Find flights", "parameters": {"origin": "string", "destination": "string"}}]},
)
LeastToMostStrategy
STRATEGY = PromptStrategy.LEAST_TO_MOST. Solve simpler sub-problems first, then build to the full answer — suited to multi-step math/logic questions.
- System: intro + language note + guardrails.
- User:
## Context→## Problem→## Solution(scaffold block). Memory also silently dropped (see warning above —LeastToMostStrategy._build_usernever readsmemeither).
STRATEGY_REGISTRY
STRATEGY_REGISTRY: Dict[PromptStrategy, BaseStrategy] = {
PromptStrategy.SIMPLE: SimpleStrategy(),
PromptStrategy.CHAIN_OF_THOUGHT: ChainOfThoughtStrategy(),
PromptStrategy.MULTI_HOP: MultiHopStrategy(),
PromptStrategy.SELF_CONSISTENT: SelfConsistentStrategy(),
PromptStrategy.STEP_BACK: StepBackStrategy(),
PromptStrategy.REACT: ReActStrategy(),
PromptStrategy.LEAST_TO_MOST: LeastToMostStrategy(),
}
A module-level dict of singleton instances, one per PromptStrategy enum member — every member has an implementation; there is no unmapped/fallback-only strategy.
Extending it: to add a new strategy, subclass BaseStrategy, implement _build_system/_build_user, add a new member to PromptStrategy (in types), add a corresponding StrategyTemplate (in templates), and register the instance in this dict. There is no dynamic/plugin-based registration mechanism — it's a hardcoded module-level dict.
get_strategy()
def get_strategy(strategy: PromptStrategy) -> BaseStrategy:
impl = STRATEGY_REGISTRY.get(strategy)
if impl is None:
logger.warning("[Strategy] Unknown strategy '%s', falling back to Simple.", strategy)
return STRATEGY_REGISTRY[PromptStrategy.SIMPLE]
return impl
Never raises — an unregistered strategy value logs a warning and silently falls back to SimpleStrategy. In practice this fallback path is unreachable through the normal PromptEngine.build() flow, since strategy is always coerced to a valid PromptStrategy enum member (or a ValueError is raised) before get_strategy is ever called — but it is reachable if you call get_strategy() directly with something that isn't a real registry key (e.g. None, or a string instead of an enum).