Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions tools/bfoptimizer/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
bfoptimizer
===========

bfoptimizer uses OpenRouter to propose and implement BPF optimizations. It
builds and tests each change, commits it, and keeps improvements after
benchmarking.

Setup
-----

Install the Python dependencies::

python3 -m pip install GitPython diskcache jinja2 numpy paramiko pint rich textual textual-plot

Install Bash and ripgrep for the agent's shell and search tools. The source
checkout also needs the usual bpfilter build and benchmark dependencies.
Neither the Anthropic SDK nor the Claude Agent SDK is required.

Set ``OPENROUTER_API_KEY`` in your environment. Run from the repository root::

python3 -m tools.bfoptimizer --sources-dir /path/to/clean/bpfilter

The source checkout must have no uncommitted or untracked files. bfoptimizer
commits validated changes and resets failed attempts to their starting commit.

Models
------

Pass OpenRouter model IDs directly. A new model needs no code change::

python3 -m tools.bfoptimizer \
--proposal-model anthropic/claude-opus-5.5 \
--impl-model anthropic/claude-sonnet-5.5 \
--commit-model anthropic/claude-haiku-5.5 \
--effort high

The proposal and implementation models default to
``anthropic/claude-opus-5.5``. The commit model defaults to the implementation
model. Proposal and implementation models must support tool calling. Commit
messages use plain text, so the commit model does not need structured outputs.

At startup, bfoptimizer checks each model ID against OpenRouter's public model
list, and checks that the proposal and implementation models support tool
calling. An ID can also use an OpenRouter preset, which is a configuration
saved in the OpenRouter web app. ``@preset/name`` alone is not checked,
because the preset selects the model. ``vendor/model@preset/name`` is
checked as ``vendor/model``.

``--effort`` accepts ``low``, ``medium``, ``high``, ``xhigh``, and ``max``.
bfoptimizer sends this as OpenRouter's unified ``reasoning.effort`` parameter.
Support and interpretation depend on the model and provider. Commit messages
use ``low`` effort and no ``max_tokens``: reasoning tokens count against that
limit, and a small one could leave no room for the message.

The displayed cost is the sum of OpenRouter's ``usage.cost`` field over every
request, commit messages included. It misses responses that time out, because
OpenRouter still bills them but bfoptimizer never receives their usage.

Agent behavior
--------------

Each phase has a limit of 100 model responses. The agent can read files, search
with ripgrep, list files with glob patterns, and execute Bash commands. The
implementation phase also exposes exact text edits and file writes under
``src/libbpfilter/cgen/``. Relative paths and shell commands start at the
repository root. The root ``AGENTS.md`` instructions are included when present.
``CLAUDE.md`` and ``CLAUDE.local.md`` are not.

The proposal prompt prohibits writes. Bash runs with the invoking user's
permissions in both phases. The file tool restrictions do not sandbox Bash.
Commands get no standard input and no ``OPENROUTER_API_KEY``, because their
output goes to the model provider.

Shell commands time out after 120 seconds. Tool output is truncated at 32,000
characters. Read returns up to 2,000 lines and ends with the offset to continue
from. Grep and Glob skip ignored files, such as the build directory. Tools
reject the arguments they do not support.

bfoptimizer retries rate limits, server errors, and network failures up to 6
times, with an exponential backoff that honors ``Retry-After``. Responses are
not streamed, so a response must arrive within 30 minutes. A response that
times out is not retried: OpenRouter keeps generating and billing it, and a
retry would likely pay for the same slow generation again. Other API failures
and exhausted response limits fail the attempt through the existing reset path.

Requests in the tool loop set a top-level ``cache_control``, which Anthropic
models need to cache the conversation from one tool round to the next.

The loop preserves reasoning details between tool calls. It does not provide
the Claude Agent SDK's context compaction, subagents, or session management.
Choose a model with enough context for the source files and tool results.

References
----------

See OpenRouter's documentation on `tool calling`_, `reasoning tokens`_, and
`prompt caching`_ for model requirements and parameter behavior.

.. _tool calling: https://openrouter.ai/docs/guides/features/tool-calling
.. _reasoning tokens: https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
.. _prompt caching: https://openrouter.ai/docs/guides/best-practices/prompt-caching
64 changes: 51 additions & 13 deletions tools/bfoptimizer/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@
from tests.benchmarks.bfbencher import compare_table

from .ai import generate_commit_message, query_implementation, query_proposal
from .api import api_key, models
from .build import MakeTarget, benchmark, configure, make
from .log import RenderAs
from .models import (
DEFAULT_MODEL,
AttemptResult,
AttemptStep,
CommitInfo,
Config,
Effort,
History,
Model,
)
from .tui import Optimizer

Expand Down Expand Up @@ -139,9 +140,7 @@ async def _optimization_loop(history: History) -> None:

await asyncio.to_thread(repo.git.add, ".")
diff = await asyncio.to_thread(repo.git.diff, "--staged")
commit_msg = await asyncio.to_thread(
generate_commit_message, attempt.plan, diff
)
commit_msg = await generate_commit_message(history, attempt.plan, diff)
log.info(f"Commit: {commit_msg}")
commit = await asyncio.to_thread(repo.index.commit, commit_msg)

Expand Down Expand Up @@ -197,6 +196,31 @@ async def _optimization_loop(history: History) -> None:
log.info("Optimization completed!")


def _check_models(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
"""Reject the model IDs that would fail only once their phase starts."""

try:
catalog = models()
except RuntimeError as error:
parser.error(str(error))

for option, model, needs_tools in (
("--proposal-model", args.proposal_model, True),
("--impl-model", args.impl_model, True),
("--commit-model", args.commit_model, False),
):
# Presets ("@preset/...") and variants (":nitro") are not catalog
# entries: check the model they apply to, if the ID names one.
base = model.partition("@")[0]
if not base:
continue
entry = catalog.get(base) or catalog.get(base.partition(":")[0])
if entry is None:
parser.error(f"{option}: {model} is not an OpenRouter model ID")
if needs_tools and "tools" not in entry.get("supported_parameters", []):
parser.error(f"{option}: {model} does not support tool calling")


def main() -> None:
parser = argparse.ArgumentParser(
prog="bfoptimizer",
Expand Down Expand Up @@ -226,17 +250,21 @@ def main() -> None:
)
_ = ai_g.add_argument(
"--proposal-model",
type=Model,
default=Model.CLAUDE_OPUS_5_5,
choices=list(Model),
help="Model for the proposal phase (default: %(default)s).",
default=DEFAULT_MODEL,
metavar="MODEL",
help="OpenRouter model ID for proposals (default: %(default)s).",
)
_ = ai_g.add_argument(
"--impl-model",
type=Model,
default=Model.CLAUDE_OPUS_5_5,
choices=list(Model),
help="Model for the implementation phase (default: %(default)s).",
default=DEFAULT_MODEL,
metavar="MODEL",
help="OpenRouter model ID for implementation (default: %(default)s).",
)
_ = ai_g.add_argument(
"--commit-model",
default=None,
metavar="MODEL",
help="OpenRouter model ID for commit messages (default: --impl-model).",
)
_ = ai_g.add_argument(
"--effort",
Expand Down Expand Up @@ -304,6 +332,13 @@ def main() -> None:

args = parser.parse_args()

try:
api_key()
except RuntimeError as error:
parser.error(str(error))

if args.commit_model is None:
args.commit_model = args.impl_model
if args.build_dir is None:
args.build_dir = args.sources_dir / "build"
if args.benchmark_exclude_flags is None:
Expand All @@ -327,8 +362,11 @@ def main() -> None:
print(repo.git.status("--short"), file=sys.stderr)
sys.exit(1)

_check_models(parser, args)

history = History(Config(**vars(args)))
Optimizer(history, lambda: _optimization_loop(history)).run()


main()
if __name__ == "__main__":
main()
Loading
Loading