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.
What's novel?
- Observable session state with reducer support. A Redux-like session state
manager keeps every tool invocation and prompt interaction observable,
replayable, and ready for automation while an in-process event bus publishes
ToolInvokedandPromptExecutedevents. Built-in planning, virtual filesystem, and Python-eval sections register reducers, enforce domain-specific validation, and expose guided Markdown so stateful runs stay deterministic. - Composable prompt blueprints with strict contracts. Prompt composition primitives let you assemble deterministic, typed sections into reusable blueprints. Prompt objects compose trees of dataclass-backed sections, render Markdown with validated placeholders, and automatically surface tool contracts so every render stays predictable.
- Override-friendly workflows that scale into optimization. First-class support for prompt overrides lays the groundwork for an optimizer that plugs into your development cycle. Prompt definitions ship with hash-based descriptors plus on-disk overrides that stay in sync through schema validation and Git-root discovery. Prompt optimizers become important as your evaluation suite matures, but they are not a day-one requirement—start by writing prompts manually and add automation once you have robust evals.
- Provider adapters that standardize tool negotiation. Provider adapters share a conversation loop that negotiates tool calls, applies JSON-schema response formats, and normalizes structured payloads, making the runtime model-agnostic.
- Local-first, deterministic execution. Everything runs locally without mandatory APIs or hosted services, and every render stays version-control-friendly so diffs capture intent instead of churn. The code-review example ties it together with override-aware prompts, session telemetry, and replayable tooling for deterministic agent runs.
Requirements
- Python 3.12+ (the repository pins 3.14 in
.python-versionfor development) uvCLI
Install
uv add weakincentives
# optional tool extras
uv add "weakincentives[asteval]"
# optional provider adapters
uv add "weakincentives[openai]"
uv add "weakincentives[litellm]"
# cloning the repo? use: uv sync --extra asteval --extra openai --extra litellm
Tutorial: Build a Stateful Code-Reviewing Agent
Use Weak Incentives to assemble a reproducible reviewer that tracks every decision, stages edits safely, and answers quick calculations inline. The runtime already ships with a session ledger and override-aware prompts, so you avoid custom state stores or ad-hoc optimizers.
1. Model review data and expected outputs
Typed dataclasses keep inputs and outputs honest so adapters emit consistent
telemetry and structured responses stay predictable. See
Dataclass Serde Utilities and
Structured Output via Prompt[OutputT] for the
validation and JSON-contract details behind this snippet.
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 session so every run supports plans, staged edits, and quick calculations.
Mount diffs ahead of time so the agent can read them through the virtual
filesystem without extra callbacks. Install the asteval extra
(uv add "weakincentives[asteval]") before instantiating AstevalSection so the
sandbox is available at runtime. Specs worth skimming:
Session State, Prompt Event Emission,
Virtual Filesystem Tools, Planning Tools,
and Asteval Integration.
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(
allowed_host_roots=(diff_root,),
mounts=(
HostMount(
host_path="octo_widgets/cache-layer.diff",
mount_path=VfsPath(("diffs", "cache-layer.diff")),
),
),
)
planning_section = PlanningToolsSection()
asteval_section = AstevalSection()
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. PlanningToolsSection, AstevalSection and VfsToolsSection pull the
active Session from ToolContext.session during tool execution, so adapters
must populate that field before dispatching calls.
3. Define a symbol search helper tool
Tools are typed callables that return structured results. Add lightweight
helpers alongside the built-in suites—in this case, a symbol searcher that reads
from a repo mounted at /srv/agent-repo. Review the
Tool Registration and Tool Error Handling
specs to match the handler and ToolResult contracts.
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,
)
Session reducers accumulate structured state across prompt and tool events.
When the symbol_search tool returns results, register a reducer that records
the queries the reviewer explored along with the snippets that satisfied each
one. Downstream sections can inspect this slice with
session.select_all(ReviewedSymbol) to summarize the investigation history.
from dataclasses import dataclass
from weakincentives.session import ToolData
@dataclass
class ReviewedSymbol:
query: str
matches: tuple[SymbolMatch, ...]
def track_reviewed_symbols(
reviewed: tuple[ReviewedSymbol, ...],
event: ToolData,
) -> tuple[ReviewedSymbol, ...]:
if event.value is None or not isinstance(event.value, SymbolSearchResult):
return reviewed
params = event.source.params
reviewed_symbol = ReviewedSymbol(
query=params.query,
matches=event.value.matches,
)
return (*reviewed, reviewed_symbol)
session.register_reducer(
SymbolSearchResult,
track_reviewed_symbols,
slice_type=ReviewedSymbol,
)
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 render through string.Template, so keep placeholders readable and
combine guidance with the tool suites into one auditable prompt tree. See the
Prompt Class and
Prompt Versioning & Persistence specs for the
rendering and hashing rules that stabilize this structure.
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 wiring above captures PromptExecuted and ToolInvoked
events automatically. For payload formats and parsing guarantees see
Adapter Evaluation and
Native OpenAI Structured Outputs.
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
Selectors expose reducer output so you can ship audit logs without extra
plumbing. Planning reducers keep only the latest Plan; register a custom
reducer before PlanningToolsSection if you need history. See
Session State and
Session Snapshots for selector and rollback rules.
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
Persist optimizer output so the runtime can swap in tuned sections without a
redeploy. LocalPromptOverridesStore is the default choice: it discovers the
workspace root, enforces descriptors, and reads JSON overrides from
.weakincentives/prompts/overrides/. Pair the
Local Prompt Overrides Store and
Prompt Versioning & Persistence specs to keep
namespace, key, and tag hashes aligned.
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 plans, VFS edits, and evaluation transcripts inside a session.
- Supports optimizer-driven overrides that fit neatly into CI or evaluation harnesses.
Run it inside a worker, bot, or scheduler; the captured session state keeps each evaluation replayable. For long-lived deployments, follow Tool-Aware Prompt Versioning to keep overrides and tool descriptors in sync.
Logging
Weak Incentives ships a structured logging adapter so hosts can add contextual
metadata to every record without manual dictionary plumbing. Call
configure_logging() during startup to install the default handler and then
bind logger instances wherever you need telemetry:
from weakincentives.logging import configure_logging, get_logger
configure_logging(json_mode=True)
logger = get_logger("demo").bind(component="cli")
logger.info("boot", event="demo.start", context={"attempt": 1})
The helper respects any existing root handlers—omit force=True if your
application already configures logging and you only want Weak Incentives to
honor the selected level. When you do want to take over the pipeline, call
configure_logging(..., force=True) and then customize the root handler list
with additional sinks (for example, forwarding records to Cloud Logging or a
structured log shipper). Each emitted record contains an event field plus a
context mapping, so downstream processors can make routing decisions without
parsing raw message strings.
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.6.0.tar.gz.
File metadata
- Download URL: weakincentives-0.6.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 |
594f1ed074d6ef93c60d9c01837f09ccc98d02684fc8efe44a8711135f89d3a6
|
|
| MD5 |
e92371396fbcc57e7da13c85ca30a853
|
|
| BLAKE2b-256 |
60ba71d9cef46165d41cdaa5806530e8ed2fed5981b4bae8b9508d605670e7de
|
Provenance
The following attestation bundles were made for weakincentives-0.6.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.6.0.tar.gz -
Subject digest:
594f1ed074d6ef93c60d9c01837f09ccc98d02684fc8efe44a8711135f89d3a6 - Sigstore transparency entry: 672838507
- Sigstore integration time:
-
Permalink:
weakincentives/weakincentives@c6646aba7e4b6e6e880f401de6bb26777392f053 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/weakincentives
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c6646aba7e4b6e6e880f401de6bb26777392f053 -
Trigger Event:
release
-
Statement type:
File details
Details for the file weakincentives-0.6.0-py3-none-any.whl.
File metadata
- Download URL: weakincentives-0.6.0-py3-none-any.whl
- Upload date:
- Size: 108.7 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 |
6fbd81f755a8f79eeba9abd8365944b8f710a36a4134d61ce811b2c59f7e332a
|
|
| MD5 |
7c7cd3a33e84da65a8e8e5eaf1b5213f
|
|
| BLAKE2b-256 |
3485b21bde4e04ce5d20e89c3846a67455a2d672dc74190570c2e06ae7a71173
|
Provenance
The following attestation bundles were made for weakincentives-0.6.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.6.0-py3-none-any.whl -
Subject digest:
6fbd81f755a8f79eeba9abd8365944b8f710a36a4134d61ce811b2c59f7e332a - Sigstore transparency entry: 672838508
- Sigstore integration time:
-
Permalink:
weakincentives/weakincentives@c6646aba7e4b6e6e880f401de6bb26777392f053 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/weakincentives
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c6646aba7e4b6e6e880f401de6bb26777392f053 -
Trigger Event:
release
-
Statement type: