Lightweight Python framework for building agentic systems where everything looks like code.
You define Python stubs with only a type signature and docstring,
decorate them with @interface, and separately bind them to concrete
implementations, which can be Python code, single calls to an LLM, or
agentic, tool-using calls to an LLM.
There is also support for implementing an Interface using only an LLM and the docstring and type signature associated with the stub.
This architecture lets you easily explore the space of different ways to decompose a problem into modular components.
uv syncDefine a function stub and bind it to an LLM-based implementation:
from secretagent.core import implement_via
@implement_via('simulate', llm={'model': 'claude-haiku-4-5-20251001'})
def translate(english_sentence: str) -> str:
"""Translate a sentence in English to French."""
print(translate("What's for lunch today?"))
# Qu'est-ce qu'il y a pour le dejeuner aujourd'hui?You can also get structured Pydantic output:
from pydantic import BaseModel
from secretagent.core import implement_via
import secretagent.implement.pydantic # registers simulate_pydantic factory
class FrenchEnglishTranslation(BaseModel):
english_text: str
french_text: str
@implement_via('simulate_pydantic', llm={'model': 'claude-haiku-4-5-20251001'})
def translate_structured(english_sentence: str) -> FrenchEnglishTranslation:
"""Translate a sentence in English to French."""
print(translate_structured("What's for lunch today?"))Run the full quickstart example:
uv run examples/quickstart.pyConfiguration is managed via secretagent.config using OmegaConf:
from secretagent import config
# load from a YAML file
config.configure(yaml_file='conf.yaml')
# or set values directly
config.configure(llm={'model': 'claude-haiku-4-5-20251001'})
# temporary overrides via context manager
with config.configuration(cachier={'enable_caching': False}):
result = my_function()Key configuration sections:
llm.model-- LLM model name passed to litellmecho.*-- control debug output (llm_input, llm_output, model, service, call)cachier.*-- caching options (enable_caching, cache_dir, etc.)evaluate.*-- experiment settings (expt_name, result_dir)
@interface-- decorator that turns a stub function into an Interface@implement_via(method, **kw)-- create an Interface and bind it in one stepinterface.implement_via(method, **kw)-- bind an existing Interface
-
'direct'-- use the function body (or another callable) as the implementation.my_iface.implement_via('direct') # use the stub's own body my_iface.implement_via('direct', fn=some_function) # use a specific callable my_iface.implement_via('direct', fn='mymod.func') # resolve a dotted name
-
'simulate'-- prompt an LLM to predict the function output from the stub's docstring and type signature.my_iface.implement_via('simulate', llm={'model': 'claude-haiku-4-5-20251001'})
-
'simulate_pydantic'-- like simulate but uses a pydantic-ai Agent, which can call tools in a ReAct-like loop and return structured Pydantic output.my_iface.implement_via('simulate_pydantic', llm={'model': 'claude-haiku-4-5-20251001'}) my_iface.implement_via('simulate_pydantic', tools='__all__') # use all other interfaces as tools my_iface.implement_via('simulate_pydantic', tools=[tool_a, tool_b]) # specific tools
-
'program_of_thought'-- generate Python code with an LLM and execute it in a sandboxed executor. Tools are available as callable functions in the generated code.my_iface.implement_via('program_of_thought', llm={'model': 'claude-haiku-4-5-20251001'}) my_iface.implement_via('program_of_thought', tools='__all__') # default: all other interfaces my_iface.implement_via('program_of_thought', tools=[tool_a]) # specific tools
-
'prompt_llm'-- use a custom prompt template with the LLM.my_iface.implement_via('prompt_llm', prompt_template_str='Translate to French: $text', llm={'model': 'claude-haiku-4-5-20251001'}) my_iface.implement_via('prompt_llm', prompt_template_file='prompts/my_template.txt', answer_pattern=r'<answer>(.*)</answer>')
All factories also accept config overrides as keyword arguments (e.g.
llm={'model': ...}, echo={'llm_input': True}), which are applied
via config.configuration() during execution.
Benchmarks live in benchmarks/. Each benchmark has its own expt.py
runner, YAML configs in conf/, and ptools (decomposed sub-tasks) in
ptools.py.
The bench CLI dispatches to per-benchmark runners as subprocesses:
# list all registered benchmarks with eval pool and minibatch sizes
uv run -m secretagent.cli.bench list
# run a single benchmark (uses default simulate strategy)
uv run -m secretagent.cli.bench run sports_understanding --minibatch
# run with a specific model
uv run -m secretagent.cli.bench run medcalc --minibatch llm.model=gemini/gemini-3.1-flash-lite-preview
# run all benchmarks
uv run -m secretagent.cli.bench run-all --minibatchEach benchmark can also be run directly with full config control:
cd benchmarks/bbh/sports_understanding
# simulate strategy (zero-shot LLM)
uv run python -m secretagent.cli.expt run \
--interface ptools.are_sports_in_sentence_consistent \
ptools.are_sports_in_sentence_consistent.method=simulate \
llm.model=gemini/gemini-3.1-flash-lite-preview dataset.n=20
# workflow strategy (multi-step pipeline)
uv run python -m secretagent.cli.expt run \
--interface ptools.are_sports_in_sentence_consistent \
ptools.are_sports_in_sentence_consistent.method=direct \
ptools.are_sports_in_sentence_consistent.fn=ptools.sports_understanding_workflow \
llm.model=gemini/gemini-3.1-flash-lite-preview dataset.n=20For benchmarks with their own expt.py (medcalc, musr, etc.):
cd benchmarks/medcalc
# simulate
uv run python expt.py run --config-file conf/simulate.yaml \
llm.model=gemini/gemini-3.1-flash-lite-preview dataset.n=110
# workflow (multi-stage extraction + Python computation)
uv run python expt.py run --config-file conf/workflow.yaml \
llm.model=gemini/gemini-3.1-flash-lite-preview dataset.n=110
# program of thought (LLM generates code)
uv run python expt.py run --config-file conf/pot.yaml \
llm.model=gemini/gemini-3.1-flash-lite-preview dataset.n=110Each benchmark supports multiple implementation strategies for the top-level interface:
| Strategy | Method | What it does |
|---|---|---|
| simulate | method=simulate |
One LLM call predicts the answer from the function docstring |
| unstructured | method=direct, fn=zeroshot_unstructured_workflow |
One LLM call with custom prompt + output formatting |
| workflow | method=direct, fn=<benchmark>_workflow |
Hand-coded multi-step pipeline calling simulated sub-ptools |
| POT | method=program_of_thought |
LLM generates Python code which is then executed |
# list experiments
uv run -m secretagent.cli.results list benchmarks/bbh/sports_understanding/results/*/
# compare accuracy across strategies
uv run -m secretagent.cli.results average --metric correct --metric cost- \
benchmarks/bbh/sports_understanding/results/*/
# plot accuracy vs cost
uv run -m secretagent.cli.results plot --metric correct --metric cost- \
--output plot.png benchmarks/bbh/sports_understanding/results/*/scripts/run_all_strategies.sh runs simulate, unstructured, workflow,
and POT across all benchmarks with consistent minibatch configs:
bash scripts/run_all_strategies.shSee docs/CLI.md for full CLI documentation.
- Python 3.11+
- An API key for your LLM provider (set in
.env):GEMINI_API_KEYfor Gemini modelsTOGETHER_API_KEYfor Together AI models (DeepSeek, Qwen, etc.)ANTHROPIC_API_KEYfor Claude models