Tools for developing and optimizing side effect free background agents
Project description
Weak Incentives
Lean, typed building blocks for side-effect-free background agents. Compose deterministic prompts, run typed tools, and parse strict JSON replies without heavy dependencies. Optional adapters snap in when you need a model provider.
Why now?
This library was built out of frustration with LangGraph and DSPy to explore better ways to do state and context management when building apps with LLMs while allowing the prompts to be automatically optimized.
Highlights
- Namespaced prompt trees with deterministic Markdown renders, placeholder verification, and tool-aware versioning metadata.
- Stdlib-only dataclass serde (
parse,dump,clone,schema) keeps request and response types honest end-to-end. - Session state container and event bus collect prompt and tool telemetry for downstream automation.
- Built-in planning and virtual filesystem tool suites give agents durable plans and sandboxed edits backed by reducers and selectors.
- Optional OpenAI and LiteLLM adapters integrate structured output parsing, tool orchestration, and telemetry hooks.
Requirements
- Python 3.12+ (the repository pins 3.14 in
.python-versionfor development) uvCLI
Install
uv add weakincentives
# optional provider adapters
uv add "weakincentives[openai]"
uv add "weakincentives[litellm]"
# cloning the repo? use: uv sync --extra openai --extra litellm
Tutorial: Build a Stateful Code-Reviewing Agent
Use Weak Incentives to assemble a reproducible reviewer that tracks every
decision, stages file edits in a sandbox, and evaluates quick calculations when
the diff raises questions. Compared to LangGraph you do not need to bolt on a
custom state store—the Session captures prompt and tool telemetry out of the
box. Unlike DSPy, prompt sections already expose versioning and override hooks
so optimizers can swap instructions without rewriting the runtime.
1. Model review data and expected outputs
Typed dataclasses keep inputs and outputs honest so adapters can emit consistent
telemetry and structured responses stay predictable. Dive into the
Dataclass Serde Utilities and Structured Output via
Prompt[OutputT] specs for the validation rules and
JSON-return guarantees that back this example.
from dataclasses import dataclass
@dataclass
class PullRequestContext:
repository: str
title: str
body: str
files_summary: str
@dataclass
class ReviewComment:
file_path: str
line: int
severity: str
summary: str
rationale: str
@dataclass
class ReviewBundle:
comments: tuple[ReviewComment, ...]
overall_assessment: str
2. Create a session, surface built-in tool suites, and mount diffs
Planning, virtual filesystem, and Python-evaluation sections register reducers on the provided session. Introducing them early keeps every evaluation capable of multi-step plans, staged edits, and quick calculations. Host mounts feed the reviewer precomputed diffs before the run begins so it can read them through the virtual filesystem tools without calling back to your orchestrator. Because each tool suite records its activity on the session, selectors later in the tutorial can recover audit logs without extra plumbing. See the Session State Specification, Prompt Event Emission, Virtual Filesystem Tool Suite, Planning Tool Suite, and Asteval Integration for the reducer contracts and tool capabilities these snippets rely on.
from pathlib import Path
from weakincentives.events import InProcessEventBus, PromptExecuted
from weakincentives.session import Session
from weakincentives.tools import (
AstevalSection,
HostMount,
PlanningToolsSection,
VfsPath,
VfsToolsSection,
)
bus = InProcessEventBus()
session = Session(bus=bus)
diff_root = Path("/srv/agent-mounts")
diff_root.mkdir(parents=True, exist_ok=True)
vfs_section = VfsToolsSection(
session=session,
allowed_host_roots=(diff_root,),
mounts=(
HostMount(
host_path="octo_widgets/cache-layer.diff",
mount_path=VfsPath(("diffs", "cache-layer.diff")),
),
),
)
planning_section = PlanningToolsSection(session=session)
asteval_section = AstevalSection(session=session)
def log_prompt(event: PromptExecuted) -> None:
print(
f"Prompt {event.prompt_name} completed with "
f"{len(event.result.tool_results)} tool calls"
)
bus.subscribe(PromptExecuted, log_prompt)
Copy unified diff files into /srv/agent-mounts before launching the run. The
host mount resolves octo_widgets/cache-layer.diff relative to that directory
and exposes it to the agent as diffs/cache-layer.diff inside the virtual
filesystem snapshot.
3. Define a symbol search helper tool
Weak Incentives tools are typed functions that return structured results. Use
them to expose deterministic helpers alongside the built-in suites. A reviewer
benefits from a lightweight code-search utility that surfaces the context around
symbols referenced in a diff. Mount a checkout of the repository under
/srv/agent-repo before launching the run so the tool can read from it. Refer to
the Tool Registration Specification and Error Handling
Revamp to mirror the expected handler contract and
ToolResult semantics.
from dataclasses import dataclass
from pathlib import Path
from weakincentives.prompt.tool import Tool, ToolResult
@dataclass
class SymbolSearchRequest:
query: str
file_glob: str = "*.py"
max_results: int = 5
@dataclass
class SymbolMatch:
file_path: str
line: int
snippet: str
@dataclass
class SymbolSearchResult:
matches: tuple[SymbolMatch, ...]
repo_root = Path("/srv/agent-repo")
def find_symbol(params: SymbolSearchRequest) -> ToolResult[SymbolSearchResult]:
if not repo_root.exists():
raise FileNotFoundError(
"Mount a repository checkout at /srv/agent-repo before running the agent."
)
matches: list[SymbolMatch] = []
for file_path in repo_root.rglob(params.file_glob):
if not file_path.is_file():
continue
with file_path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
if params.query in line:
matches.append(
SymbolMatch(
file_path=str(file_path.relative_to(repo_root)),
line=line_number,
snippet=line.strip(),
)
)
if len(matches) >= params.max_results:
break
if len(matches) >= params.max_results:
break
return ToolResult(
message=f"Found {len(matches)} matching snippets.",
value=SymbolSearchResult(matches=tuple(matches)),
)
symbol_search_tool = Tool[SymbolSearchRequest, SymbolSearchResult](
name="symbol_search",
description=(
"Search the repository checkout for a symbol and return file snippets."
),
handler=find_symbol,
)
Attach custom tools to sections (next step) so the adapter can call them and record their outputs on the session alongside built-in reducers. The prompt can now chase suspicious references without delegating work back to the orchestrator.
4. Compose the prompt with deterministic sections
Sections rely on string.Template, so prepare readable placeholders up front.
Combine your review instructions with the built-in tool suites to publish a
single, auditable prompt tree. The Prompt Class Specification
and Prompt Versioning & Persistence detail the
rendering rules and hashing metadata that keep this structure stable.
from weakincentives import MarkdownSection, Prompt
@dataclass
class ReviewGuidance:
severity_scale: str = "minor | major | critical"
output_schema: str = "ReviewBundle with comments[] and overall_assessment"
focus_areas: str = (
"Security regressions, concurrency bugs, test coverage gaps, and"
" ambiguous logic should be escalated."
)
overview_section = MarkdownSection[PullRequestContext](
title="Repository Overview",
key="review.overview",
template="""
You are a principal engineer reviewing a pull request.
Repository: ${repository}
Title: ${title}
Pull request summary:
${body}
Files touched: ${files_summary}
""",
)
analysis_section = MarkdownSection[ReviewGuidance](
title="Review Directives",
key="review.directives",
template="""
- Classify findings using this severity scale: ${severity_scale}.
- Emit output that matches ${output_schema}; missing fields fail the run.
- Investigation focus:
${focus_areas}
- Inspect mounted diffs under `diffs/` with `vfs_read_file` before
commenting on unfamiliar hunks.
- Reach for `symbol_search` when you need surrounding context from the
repository checkout.
""",
tools=(symbol_search_tool,),
default_params=ReviewGuidance(),
)
review_prompt = Prompt[ReviewBundle](
ns="tutorial/code_review",
key="review.generate",
name="code_review_agent",
sections=(
overview_section,
planning_section,
vfs_section,
asteval_section,
analysis_section,
),
)
rendered = review_prompt.render(
PullRequestContext(
repository="octo/widgets",
title="Add caching layer",
body="Introduces memoization to reduce redundant IO while preserving correctness.",
files_summary="loader.py, cache.py",
),
ReviewGuidance(),
)
print(rendered.text)
print([tool.name for tool in rendered.tools])
5. Evaluate the prompt with an adapter
Adapters send the rendered prompt to a provider and publish telemetry to the
event bus. The session subscribed above automatically ingests each
PromptExecuted and ToolInvoked event. Review the Adapter Evaluation
Specification and Native OpenAI Structured
Outputs for provider payload
expectations and parsing guarantees.
from weakincentives.adapters.openai import OpenAIAdapter
adapter = OpenAIAdapter(
model="gpt-4o-mini",
client_kwargs={"api_key": "sk-..."},
)
response = adapter.evaluate(
review_prompt,
PullRequestContext(
repository="octo/widgets",
title="Add caching layer",
body="Introduces memoization to reduce redundant IO while preserving correctness.",
files_summary="loader.py, cache.py",
),
bus=bus,
)
bundle = response.output
if bundle is None:
raise RuntimeError("Structured parsing failed")
for comment in bundle.comments:
print(f"{comment.file_path}:{comment.line} → {comment.summary}")
If the model omits a required field, OpenAIAdapter raises PromptEvaluationError
with provider context rather than silently degrading.
6. Mine session state for downstream automation
Built-in selectors expose the data collected by reducers that each tool suite
registered. This gives you ready-to-ship audit logs without building LangGraph
callbacks or DSPy side channels. Planning reducers keep only the latest Plan
snapshot; register your own reducer before instantiating PlanningToolsSection
if you need to retain a historical ledger alongside the current state. Consult
the Session State Specification and Session Snapshots
Specification to understand how selectors and
rollbacks behave in production runs.
from weakincentives.session import select_latest
from weakincentives.tools import Plan, VirtualFileSystem
latest_plan = select_latest(session, Plan)
vfs_snapshot = select_latest(session, VirtualFileSystem)
if latest_plan:
print(f"Plan objective: {latest_plan.objective}")
for step in latest_plan.steps:
print(f"- [{step.status}] {step.title}")
else:
print("No plan recorded yet.")
if vfs_snapshot:
for file in vfs_snapshot.files:
print(f"Staged file {file.path.segments} (version {file.version})")
7. Override sections with an overrides store
DSPy-style optimizers can persist improved instructions and let the runtime swap
them in without redeploying code. For most projects the
LocalPromptOverridesStore is the recommended implementation—it discovers the
workspace root, enforces descriptor metadata, and reads JSON overrides from the
.weakincentives/prompts/overrides/ tree described in the
Local Prompt Overrides Store Specification.
Pair it with the Prompt Versioning & Persistence
guidance to understand how namespace, prompt key, and tag hashing keep overrides
pinned to the right sections and tools.
from pathlib import Path
from weakincentives.prompt.local_prompt_overrides_store import (
LocalPromptOverridesStore,
)
from weakincentives.prompt.versioning import (
PromptDescriptor,
PromptOverride,
SectionOverride,
)
workspace_root = Path("/srv/agent-workspace")
overrides_store = LocalPromptOverridesStore(root_path=workspace_root)
descriptor = PromptDescriptor.from_prompt(review_prompt)
seed_override = overrides_store.seed_if_necessary(
review_prompt, tag="assertive-feedback"
)
section_path = ("review", "directives")
section_descriptor = next(
section
for section in descriptor.sections
if section.path == section_path
)
custom_override = PromptOverride(
ns=descriptor.ns,
prompt_key=descriptor.key,
tag="assertive-feedback",
sections={
**seed_override.sections,
section_path: SectionOverride(
expected_hash=section_descriptor.content_hash,
body="\n".join(
(
"- Classify findings using this severity scale: minor | major | critical.",
"- Always cite the exact diff hunk when raising a major or critical issue.",
"- Respond with ReviewBundle JSON. Missing fields terminate the run.",
)
),
),
},
tool_overrides=seed_override.tool_overrides,
)
persisted_override = overrides_store.upsert(descriptor, custom_override)
rendered_with_override = review_prompt.render_with_overrides(
PullRequestContext(
repository="octo/widgets",
title="Add caching layer",
body="Introduces memoization to reduce redundant IO while preserving correctness.",
files_summary="loader.py, cache.py",
),
overrides_store=overrides_store,
tag=persisted_override.tag,
)
print(rendered_with_override.text)
The overrides store writes atomically to
.weakincentives/prompts/overrides/{ns}/{prompt_key}/{tag}.json inside the
workspace described in the
Local Prompt Overrides Store Specification.
Optimizers and prompt engineers can still drop JSON overrides into that tree by
hand—checked into source control or generated during evaluations—without
subclassing PromptOverridesStore. Because sections expose stable (ns, key, path) identifiers, overrides stay scoped to the intended content so teams can
iterate on directives without risking accidental drift elsewhere in the tree.
8. Ship it
You now have a deterministic reviewer that:
- Enforces typed contracts for inputs, tools, and outputs.
- Persists multi-step plans, VFS edits, and evaluation transcripts inside a session without custom plumbing.
- Supports optimizer-driven overrides that slot cleanly into CI, evaluation harnesses, or on-call tuning workflows.
Drop the agent into a queue worker, Slack bot, or scheduled job. Every evaluation is replayable thanks to the captured session state, so postmortems start with facts—not speculation. For long-lived deployments, pair these patterns with the Tool-Aware Prompt Versioning guidance to keep overrides and tooling descriptions aligned as the code evolves.
Development Setup
-
Install Python 3.14 (for example with
pyenv install 3.14.0). -
Install
uv, then bootstrap the environment and hooks:uv sync ./install-hooks.sh -
Run checks with
uv runso everything shares the managed virtualenv:make format/make format-checkmake lint/make lint-fixmake typecheck(Ty + Pyright, warnings fail the build)make test(pytest viabuild/run_pytest.py, 100% coverage enforced)make check(aggregates the quiet checks above plus Bandit, Deptry, pip-audit, and markdown linting)
Documentation
AGENTS.md— operational handbook and contributor workflow.specs/— design docs for prompts, planning tools, and adapters.ROADMAP.md— upcoming feature sketches.docs/api/— API reference material.
License
Apache 2.0 • Status: Alpha (APIs may change between releases)
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file weakincentives-0.5.0.tar.gz.
File metadata
- Download URL: weakincentives-0.5.0.tar.gz
- Upload date:
- Size: 1.9 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
043666d4404271ac28c878cd688f90f2dedd5bb221bd9db070bff15af727f657
|
|
| MD5 |
a65541ebd010e030b270db6f77062a0b
|
|
| BLAKE2b-256 |
24a574fa2621990a12dd7772594381381bd282cfbde1563f60e16616613e26eb
|
Provenance
The following attestation bundles were made for weakincentives-0.5.0.tar.gz:
Publisher:
release.yml on weakincentives/weakincentives
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
weakincentives-0.5.0.tar.gz -
Subject digest:
043666d4404271ac28c878cd688f90f2dedd5bb221bd9db070bff15af727f657 - Sigstore transparency entry: 662063140
- Sigstore integration time:
-
Permalink:
weakincentives/weakincentives@07efba641b322142cdc8ae9c175df01958f488c0 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/weakincentives
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@07efba641b322142cdc8ae9c175df01958f488c0 -
Trigger Event:
release
-
Statement type:
File details
Details for the file weakincentives-0.5.0-py3-none-any.whl.
File metadata
- Download URL: weakincentives-0.5.0-py3-none-any.whl
- Upload date:
- Size: 95.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
16d3467ae8bbe2cc5207af979d2e9725aa7fdd20bc46bf41c243c43fe5c203ef
|
|
| MD5 |
79cbf49f041737bf8c813b8ce222d414
|
|
| BLAKE2b-256 |
e6994ea70fa6f8684bcb086feab784453946e98190d55bcfd235487a58f9a1fd
|
Provenance
The following attestation bundles were made for weakincentives-0.5.0-py3-none-any.whl:
Publisher:
release.yml on weakincentives/weakincentives
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
weakincentives-0.5.0-py3-none-any.whl -
Subject digest:
16d3467ae8bbe2cc5207af979d2e9725aa7fdd20bc46bf41c243c43fe5c203ef - Sigstore transparency entry: 662063143
- Sigstore integration time:
-
Permalink:
weakincentives/weakincentives@07efba641b322142cdc8ae9c175df01958f488c0 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/weakincentives
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@07efba641b322142cdc8ae9c175df01958f488c0 -
Trigger Event:
release
-
Statement type: