Tactics
A Tactic is the top-level abstraction in LLLM — the "program" that wires agents (callers) to prompts (functions). It is the uppermost building block of an agentic system, designed to be local, functional, and composable.
The relationship to the rest of the framework:
- Prompts define what an agent can do on a single turn (template + parser + tools + handler).
- Agents execute prompts via the agent call loop, managing dialogs and retries.
- Tactics define how a group of agents collaborates to solve a task.
A tactic accepts str | BaseModel as input and returns str | BaseModel as output. Subclasses narrow these types for their specific interface.
Defining a Tactic
Subclass Tactic, set name and agent_group, implement call():
from lllm import Tactic
class Analytica(Tactic):
name = "analytica"
agent_group = ["analyzer", "synthesizer"]
def call(self, task: str, **kwargs) -> str:
analyzer = self.agents["analyzer"]
synthesizer = self.agents["synthesizer"]
analyzer.open("work", prompt_args={"task": task})
analysis = analyzer.respond()
synthesizer.open("synth", prompt_args={"analysis": analysis.content})
return synthesizer.respond().content
Tactics register themselves automatically through __init_subclass__ — once the class is imported, it's available to build_tactic.
Agent Initialization
agent_group lists the agent config keys this tactic needs. At construction time, the tactic reads agent_group_configs from the config dict and parses each entry into an AgentSpec:
# config.yaml
agent_group_configs:
analyzer:
model_name: o4-mini-2025-04-16
system_prompt_path: analytica/analyzer_system
temperature: 0.1
max_completion_tokens: 20000
synthesizer:
model_name: o4-mini-2025-04-16
system_prompt_path: analytica/synthesizer_system
temperature: 0.1
max_completion_tokens: 20000
AgentSpec separates config parsing from agent construction. Known keys (model_name, system_prompt_path, api_type) are extracted; everything else becomes model_args (temperature, max_tokens, etc.). This means config errors (missing model_name, unknown prompt path) surface early with clear messages, before any LLM call is made.
Stateless Execution & Per-Call Isolation
A tactic is stateless across calls. Each __call__ creates a shallow copy of the tactic with fresh agents:
session = tactic("Analyze this paper") # call 1
session2 = tactic("Analyze this other paper") # call 2 — no interference
Internally, _execute does:
copy.copy(self)— shares immutable state (config, runtime, invoker, agent specs).- Builds fresh
Agentinstances from specs — each with empty_dialogs. - Wraps agents in
_TrackedAgentproxies for transparent session recording. - Runs
call()on the copy.
This means concurrent calls (via bcall or threads) never share mutable state. Each call gets its own agents, its own dialogs, its own session.
Transparent Session Tracking
Every agent.respond() call inside a tactic is automatically recorded into a TacticCallSession. This happens via _TrackedAgent — a thin proxy that intercepts respond() and records the AgentCallSession, then delegates everything else to the real agent.
The developer writes normal Agent API code — tracking is invisible:
def call(self, task: str, **kwargs) -> str:
analyzer = self.agents["analyzer"]
analyzer.open("work", prompt_args={"task": task})
result = analyzer.respond() # auto-recorded into TacticCallSession
return result.content
To access the full session with costs and diagnostics, use return_session=True:
session = tactic("Analyze this paper", return_session=True)
print(session.total_cost) # aggregated across all agents
print(session.agent_call_count) # how many agent.respond() calls
print(session.summary()) # human-readable overview
print(session.delivery) # the return value of call()
TacticCallSession
| Field | Type | Description |
|---|---|---|
tactic_name |
str |
Name of the tactic |
state |
str |
"initial", "running", "success", or "failure" |
agent_sessions |
Dict[str, List[AgentCallSession]] |
Per-agent call traces |
sub_tactic_sessions |
Dict[str, List[TacticCallSession]] |
Per-sub-tactic call traces |
delivery |
Any |
The return value of call() on success |
error |
str \| None |
Error description on failure |
Key properties:
session.agent_cost # cost from this tactic's agents only
session.sub_tactic_cost # cost from sub-tactic calls
session.total_cost # agent_cost + sub_tactic_cost (recursive)
session.agent_call_count # number of agent.respond() calls
Sub-Tactic Composition
Tactics compose like nn.Module child modules. Assign a tactic as an attribute and it's automatically tracked:
class ResearchTactic(Tactic):
name = "research"
agent_group = ["planner"]
def __init__(self, config, ckpt_dir, stream=None, runtime=None):
super().__init__(config, ckpt_dir, stream, runtime)
self.analyzer = build_tactic(config, ckpt_dir, stream,
name="analytica", runtime=runtime)
self.searcher = build_tactic(config, ckpt_dir, stream,
name="searcher", runtime=runtime)
def call(self, task: str, **kwargs) -> str:
planner = self.agents["planner"]
planner.open("plan", prompt_args={"task": task})
plan = planner.respond()
# Sub-tactic calls — each gets its own isolated agents
analysis = self.analyzer(plan.content)
search = self.searcher(task)
planner.receive(f"Analysis: {analysis}\nSearch: {search}")
return planner.respond().content
To record sub-tactic sessions into the parent's TacticCallSession, pass return_session=True and record manually:
sub_session = self.analyzer(plan.content, return_session=True)
self._session.record_sub_tactic_call("analyzer", sub_session)
result = sub_session.delivery
Access sub-tactics:
Tactic Inheritance
Concrete tactics can be subclassed just like any Python class, letting you build reusable pipeline bases and extend them with additional stages.
Extending a concrete tactic
class WritingPipeline(Tactic):
"""Reusable base: outline → draft."""
name = "writing_pipeline"
agent_group = ["outliner", "writer"]
def call(self, task: str) -> str:
outliner = self.agents["outliner"]
writer = self.agents["writer"]
outliner.open("outline")
outliner.receive(f"Create a concise outline about: {task}")
outline = outliner.respond().content
writer.open("write")
writer.receive(f"Expand this outline:\n\n{outline}")
return writer.respond().content
class EditedWritingPipeline(WritingPipeline):
"""Adds an editing stage on top of the base pipeline."""
name = "edited_writing_pipeline"
agent_group = ["outliner", "writer", "editor"]
def call(self, task: str) -> str:
draft = super().call(task) # reuse parent's outline→draft logic
editor = self.agents["editor"]
editor.open("edit")
editor.receive(f"Polish this draft:\n\n{draft}")
return editor.respond().content
super().call(task) works because LLLM builds agents for all names in the subclass's agent_group before calling call(). The parent's call() finds outliner and writer in self.agents; the child adds editor on top.
Abstract base tactics with register=False
Use register=False to create base classes with shared helpers that should not appear in the registry:
class BasePipelineTactic(Tactic, register=False):
"""Common helpers — not registerable."""
def _run_stage(self, agent_name: str, dialog: str, message: str) -> str:
agent = self.agents[agent_name]
agent.open(dialog)
agent.receive(message)
return agent.respond().content
class SummaryPipeline(BasePipelineTactic):
name = "summary_pipeline"
agent_group = ["extractor", "writer"]
def call(self, text: str) -> str:
facts = self._run_stage("extractor", "extract", f"Extract key facts:\n{text}")
summary = self._run_stage("writer", "write", f"Summarise:\n{facts}")
return summary
Typed I/O inheritance
Subclasses can also tighten or widen the I/O types:
class BaseAnalysisTactic(Tactic, register=False):
agent_group = ["analyzer"]
def call(self, task: str) -> str:
agent = self.agents["analyzer"]
agent.open("analyze")
agent.receive(task)
return agent.respond().content
class StructuredAnalysisTactic(BaseAnalysisTactic):
name = "structured_analysis"
def call(self, task: str) -> AnalysisOutput:
raw = super().call(task)
return AnalysisOutput.model_validate_json(raw)
See the working examples in examples/advanced/multi_agent_tactic.py and examples/code_review_service/tactics/code_review.py.
Batch & Concurrent Execution
Tactics provide built-in concurrent execution via thread pools. LLM API calls are I/O-bound, so threads are ideal (the GIL is released during network waits). Each task gets its own isolated agents — no lock contention.
Synchronous Batch
tasks = ["Analyze paper A", "Analyze paper B", "Analyze paper C"]
results = tactic.bcall(tasks, max_workers=3)
# results[0] corresponds to tasks[0], etc.
Returns results in the same order as inputs. Exceptions propagate from the first failed task.
Async Single
Runs _execute in the default thread executor so it doesn't block the event loop.
Async Concurrent (Fastest-First)
async for idx, result in tactic.ccall(tasks, max_workers=3):
print(f"Task {idx} finished: {result}")
Yields (index, result) tuples as tasks complete — not in input order. The index lets you match results to inputs.
All three methods accept return_sessions=True to get TacticCallSession objects instead of plain results.
Typed I/O with BaseModel
For shareable tactics, define typed inputs and outputs so consumers can inspect the schema:
from pydantic import BaseModel
class AnalysisInput(BaseModel):
topic: str
depth: int = 3
include_sources: bool = True
class AnalysisOutput(BaseModel):
reasoning: str
conclusion: str
confidence: float
sources: list[str] = []
class Analytica(Tactic):
name = "analytica"
agent_group = ["analyzer", "synthesizer"]
def call(self, task: AnalysisInput, **kwargs) -> AnalysisOutput:
analyzer = self.agents["analyzer"]
analyzer.open("work", prompt_args={
"topic": task.topic,
"depth": task.depth,
})
analysis = analyzer.respond()
parsed = analysis.parsed
return AnalysisOutput(
reasoning=parsed["xml_tags"]["reasoning"][0],
conclusion=parsed["xml_tags"]["answer"][0],
confidence=float(parsed["xml_tags"].get("confidence", [0.5])[0]),
)
Simple tactics can just use str in and str out — both work through the same __call__ wrapper.
Quick Constructor
For prototyping without config files or discovery:
agent = Tactic.quick("You are a helpful assistant.", model="gpt-4o")
agent.open("chat")
agent.receive("What is the capital of France?")
print(agent.respond().content)
This returns a raw Agent (not a Tactic) — the same object a full tactic would construct internally. No YAML, no TOML, no subclass needed.
Registration & Building
Tactics register automatically when their class is defined (via __init_subclass__). To build a tactic from config:
from lllm import build_tactic
config = load_yaml_config("config/experiment.yaml")
tactic = build_tactic(config, ckpt_dir="./runs", name="analytica")
result = tactic("Analyze this paper")
Or let the config specify the tactic type:
# config/experiment.yaml
tactic_type: analytica
agent_group_configs:
analyzer:
model_name: o4-mini-2025-04-16
system_prompt_path: analytica/analyzer_system
temperature: 0.1
config = load_yaml_config("config/experiment.yaml")
tactic = build_tactic(config, ckpt_dir="./runs") # reads tactic_type from config
Design Notes
- Tactics are stateless. Per-call data lives on
TacticCallSession, not on the tactic. This makes tactics safe for concurrent use and easy to reason about. - Agents are per-call. Each execution builds fresh agents from specs. The expensive objects (invoker, runtime, prompts) are shared; only the mutable
Agentshell is new. - Tracking is transparent.
_TrackedAgentproxies interceptrespond()without changing the Agent API. Developers never need to remember to "record" calls. - Thread pool, not multiprocessing. LLM calls are I/O-bound. Threads share the invoker's HTTP connection pool and avoid pickling issues. The GIL is released during network waits.
Tacticis an ABC.call()is an abstract method — you must override it. The framework raisesTypeErrorat instantiation time if you forget, not at call time.