buildwright 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- buildwright/__init__.py +3 -0
- buildwright/agents.py +75 -0
- buildwright/cli.py +83 -0
- buildwright/config.py +52 -0
- buildwright/graph.py +50 -0
- buildwright/models.yaml +46 -0
- buildwright/observability.py +41 -0
- buildwright/py.typed +0 -0
- buildwright/registry.py +59 -0
- buildwright-0.1.0.dist-info/METADATA +119 -0
- buildwright-0.1.0.dist-info/RECORD +14 -0
- buildwright-0.1.0.dist-info/WHEEL +4 -0
- buildwright-0.1.0.dist-info/entry_points.txt +2 -0
- buildwright-0.1.0.dist-info/licenses/LICENSE +21 -0
buildwright/__init__.py
ADDED
buildwright/agents.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from pydantic_ai import Agent
|
|
9
|
+
from pydantic_ai.models import Model
|
|
10
|
+
|
|
11
|
+
from .config import ROLES, Settings
|
|
12
|
+
from .registry import ModelRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Plan(BaseModel):
|
|
16
|
+
"""Structured output of the planner agent."""
|
|
17
|
+
|
|
18
|
+
steps: list[str] = Field(min_length=1, max_length=5)
|
|
19
|
+
needs_strong_model: bool = Field(
|
|
20
|
+
default=False,
|
|
21
|
+
description="true only when the task needs the strong remote model",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Review(BaseModel):
|
|
26
|
+
"""Structured output of the reviewer agent."""
|
|
27
|
+
|
|
28
|
+
verdict: Literal["pass", "fail"]
|
|
29
|
+
comments: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ModelFactory = Callable[[str], Model] # role -> Model (tests use TestModel)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Wright:
|
|
37
|
+
"""Agent factory: each role wired to the model assigned to it in Settings."""
|
|
38
|
+
|
|
39
|
+
settings: Settings
|
|
40
|
+
registry: ModelRegistry | None = None
|
|
41
|
+
model_factory: ModelFactory | None = None
|
|
42
|
+
|
|
43
|
+
def model_for(self, role: str) -> Model:
|
|
44
|
+
if role not in ROLES:
|
|
45
|
+
raise KeyError(f"unknown role '{role}'. Known: {', '.join(ROLES)}")
|
|
46
|
+
if self.model_factory is not None:
|
|
47
|
+
return self.model_factory(role)
|
|
48
|
+
assert self.registry is not None, "registry required without model_factory"
|
|
49
|
+
return self.registry.build(self.settings.role_model(role))
|
|
50
|
+
|
|
51
|
+
def planner(self) -> Agent[None, Plan]:
|
|
52
|
+
return Agent(
|
|
53
|
+
self.model_for("planner"),
|
|
54
|
+
output_type=Plan,
|
|
55
|
+
instructions=(
|
|
56
|
+
"You are a task planner. Break the user task into 1-5 concrete steps. "
|
|
57
|
+
"Set needs_strong_model=true only for hard reasoning or code-heavy tasks."
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def worker(self) -> Agent[None, str]:
|
|
62
|
+
return Agent(
|
|
63
|
+
self.model_for("worker"),
|
|
64
|
+
instructions="You are a meticulous engineer. Execute the given step. Be concise.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def reviewer(self) -> Agent[None, Review]:
|
|
68
|
+
return Agent(
|
|
69
|
+
self.model_for("reviewer"),
|
|
70
|
+
output_type=Review,
|
|
71
|
+
instructions=(
|
|
72
|
+
"You are a strict reviewer. Judge the work against the task; "
|
|
73
|
+
"reply verdict pass or fail with short comments."
|
|
74
|
+
),
|
|
75
|
+
)
|
buildwright/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .agents import Wright
|
|
7
|
+
from .config import ROLES, Settings
|
|
8
|
+
from .graph import build_graph
|
|
9
|
+
from .observability import get_tracer
|
|
10
|
+
from .registry import ModelRegistry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _wright(settings: Settings) -> Wright:
|
|
14
|
+
registry = ModelRegistry.load(settings.models_file)
|
|
15
|
+
return Wright(settings=settings, registry=registry)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_models(settings: Settings) -> None:
|
|
19
|
+
registry = ModelRegistry.load(settings.models_file)
|
|
20
|
+
for kind in ("local", "remote"):
|
|
21
|
+
print(f"\n{kind.upper()}")
|
|
22
|
+
for s in registry.list(kind):
|
|
23
|
+
key = f" [key: {s.api_key_env}]" if s.api_key_env else ""
|
|
24
|
+
print(f" {s.id:<24} {s.label}{key}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_roles(settings: Settings) -> None:
|
|
28
|
+
ModelRegistry.load(settings.models_file) # validates the file
|
|
29
|
+
print("\nrole model-id model wire")
|
|
30
|
+
registry = ModelRegistry.load(settings.models_file)
|
|
31
|
+
for role in ROLES:
|
|
32
|
+
model_id = settings.role_model(role)
|
|
33
|
+
wire = registry.spec(model_id).model or model_id.split("/", 1)[1]
|
|
34
|
+
print(f"{role:<12} {model_id:<24} {wire}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_ask(settings: Settings, task: str) -> None:
|
|
38
|
+
board = _wright(settings)
|
|
39
|
+
graph = build_graph(board)
|
|
40
|
+
tracer = get_tracer(settings)
|
|
41
|
+
|
|
42
|
+
with tracer.span("buildwright.ask", task=task):
|
|
43
|
+
state = graph.invoke({"task": task})
|
|
44
|
+
|
|
45
|
+
plan = state["plan"]
|
|
46
|
+
print("\n--- plan", f"({settings.role_model('planner')}) ---")
|
|
47
|
+
for i, step in enumerate(plan.steps, 1):
|
|
48
|
+
print(f" {i}. {step}")
|
|
49
|
+
print("\n--- answer", f"({settings.role_model('worker')}) ---")
|
|
50
|
+
print(state["answer"])
|
|
51
|
+
review = state["review"]
|
|
52
|
+
print(f"\n--- review ({settings.role_model('reviewer')}): {review.verdict} ---")
|
|
53
|
+
print(f" {review.comments}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> None:
|
|
57
|
+
parser = argparse.ArgumentParser(
|
|
58
|
+
prog="buildwright",
|
|
59
|
+
description="Per-agent model routing: one model per role, local and remote in the same registry.",
|
|
60
|
+
)
|
|
61
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
62
|
+
sub.add_parser("models", help="list the models in the registry")
|
|
63
|
+
sub.add_parser("roles", help="show the role -> model mapping")
|
|
64
|
+
ask = sub.add_parser("ask", help="run the plan -> execute -> review pipeline")
|
|
65
|
+
ask.add_argument("task")
|
|
66
|
+
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
settings = Settings()
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if args.cmd == "models":
|
|
72
|
+
cmd_models(settings)
|
|
73
|
+
elif args.cmd == "roles":
|
|
74
|
+
cmd_roles(settings)
|
|
75
|
+
else:
|
|
76
|
+
cmd_ask(settings, args.task)
|
|
77
|
+
except (KeyError, RuntimeError) as exc:
|
|
78
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
buildwright/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
ROLES = ("planner", "worker", "reviewer")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_models_file() -> Path:
|
|
14
|
+
"""Registry resolution: project-local ./models.yaml > the bundled models.yaml."""
|
|
15
|
+
local = Path("models.yaml")
|
|
16
|
+
if local.is_file():
|
|
17
|
+
return local
|
|
18
|
+
return Path(str(resources.files("buildwright").joinpath("models.yaml")))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Settings(BaseSettings):
|
|
22
|
+
"""Central configuration. The role->model mapping is the switchboard itself."""
|
|
23
|
+
|
|
24
|
+
model_config = SettingsConfigDict(
|
|
25
|
+
env_prefix="BW_",
|
|
26
|
+
env_file=".env",
|
|
27
|
+
env_file_encoding="utf-8",
|
|
28
|
+
extra="ignore",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
models_file: Path = Field(default_factory=default_models_file)
|
|
32
|
+
|
|
33
|
+
# role -> registry id (models.yaml)
|
|
34
|
+
role_planner: str = "local/qwen3"
|
|
35
|
+
role_worker: str = "remote/gpt-4o-mini"
|
|
36
|
+
role_reviewer: str = "local/qwen3"
|
|
37
|
+
|
|
38
|
+
# Langfuse: no keys -> tracing disabled (NullTracer)
|
|
39
|
+
langfuse_public_key: str | None = None
|
|
40
|
+
langfuse_secret_key: str | None = None
|
|
41
|
+
langfuse_host: str = "http://localhost:3000"
|
|
42
|
+
|
|
43
|
+
def role_model(self, role: str) -> str:
|
|
44
|
+
value = getattr(self, f"role_{role}", None)
|
|
45
|
+
if value is None:
|
|
46
|
+
raise KeyError(f"unknown role '{role}'. Known: {', '.join(ROLES)}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@lru_cache
|
|
51
|
+
def get_settings() -> Settings:
|
|
52
|
+
return Settings()
|
buildwright/graph.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
from langgraph.graph import END, START, StateGraph
|
|
6
|
+
|
|
7
|
+
from .agents import Plan, Review, Wright
|
|
8
|
+
|
|
9
|
+
# Each node is ONE PydanticAI agent; the model is decided by the role
|
|
10
|
+
# (Settings), never by the node. Assembling a CRAG or changing the flow
|
|
11
|
+
# therefore touches zero lines of model code.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PipelineState(TypedDict, total=False):
|
|
15
|
+
task: str
|
|
16
|
+
plan: Plan
|
|
17
|
+
step_outputs: list[str]
|
|
18
|
+
answer: str
|
|
19
|
+
review: Review
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_graph(board: Wright):
|
|
23
|
+
planner = board.planner()
|
|
24
|
+
worker = board.worker()
|
|
25
|
+
reviewer = board.reviewer()
|
|
26
|
+
|
|
27
|
+
def plan(state: PipelineState) -> dict:
|
|
28
|
+
result = planner.run_sync(f"Task: {state['task']}")
|
|
29
|
+
return {"plan": result.output}
|
|
30
|
+
|
|
31
|
+
def execute(state: PipelineState) -> dict:
|
|
32
|
+
outputs: list[str] = []
|
|
33
|
+
for i, step in enumerate(state["plan"].steps, start=1):
|
|
34
|
+
r = worker.run_sync(f"Task: {state['task']}\nStep {i}: {step}")
|
|
35
|
+
outputs.append(r.output)
|
|
36
|
+
return {"step_outputs": outputs, "answer": "\n\n".join(outputs)}
|
|
37
|
+
|
|
38
|
+
def review(state: PipelineState) -> dict:
|
|
39
|
+
r = reviewer.run_sync(f"Task: {state['task']}\n\nWork to review:\n{state['answer']}")
|
|
40
|
+
return {"review": r.output}
|
|
41
|
+
|
|
42
|
+
g = StateGraph(PipelineState)
|
|
43
|
+
g.add_node("plan", plan) # cheap/local model
|
|
44
|
+
g.add_node("execute", execute) # strong/remote model
|
|
45
|
+
g.add_node("review", review) # cheap/local model
|
|
46
|
+
g.add_edge(START, "plan")
|
|
47
|
+
g.add_edge("plan", "execute")
|
|
48
|
+
g.add_edge("execute", "review")
|
|
49
|
+
g.add_edge("review", END)
|
|
50
|
+
return g.compile()
|
buildwright/models.yaml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Model registry: local and remote models in one place.
|
|
2
|
+
# Agents never pick a model themselves: role -> id is defined in config
|
|
3
|
+
# (BW_ROLE_* or the project's runtime.yaml)
|
|
4
|
+
models:
|
|
5
|
+
# ---------------- local ----------------
|
|
6
|
+
- id: local/qwen3
|
|
7
|
+
label: Qwen3 8B (Ollama)
|
|
8
|
+
kind: local
|
|
9
|
+
base_url: http://localhost:11434/v1
|
|
10
|
+
model: qwen3:8b
|
|
11
|
+
|
|
12
|
+
- id: local/llamacpp
|
|
13
|
+
label: llama.cpp server (GGUF)
|
|
14
|
+
kind: local
|
|
15
|
+
base_url: http://localhost:8080/v1
|
|
16
|
+
model: local-model
|
|
17
|
+
|
|
18
|
+
# ---------------- remote ----------------
|
|
19
|
+
- id: remote/gpt-4o-mini
|
|
20
|
+
label: GPT-4o mini
|
|
21
|
+
kind: remote
|
|
22
|
+
base_url: https://api.openai.com/v1
|
|
23
|
+
model: gpt-4o-mini
|
|
24
|
+
api_key_env: OPENAI_API_KEY
|
|
25
|
+
|
|
26
|
+
- id: remote/gpt-4o
|
|
27
|
+
label: GPT-4o
|
|
28
|
+
kind: remote
|
|
29
|
+
base_url: https://api.openai.com/v1
|
|
30
|
+
model: gpt-4o
|
|
31
|
+
api_key_env: OPENAI_API_KEY
|
|
32
|
+
|
|
33
|
+
- id: remote/claude-sonnet
|
|
34
|
+
label: Claude Sonnet 4.5
|
|
35
|
+
kind: remote
|
|
36
|
+
base_url: https://api.anthropic.com/v1
|
|
37
|
+
model: claude-sonnet-4-5
|
|
38
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
39
|
+
|
|
40
|
+
# --------- via LiteLLM proxy (optional, docker-compose) ---------
|
|
41
|
+
- id: litellm/gpt-4o-mini
|
|
42
|
+
label: GPT-4o mini via LiteLLM
|
|
43
|
+
kind: remote
|
|
44
|
+
base_url: http://localhost:4000/v1
|
|
45
|
+
model: gpt-4o-mini
|
|
46
|
+
api_key_env: LITELLM_API_KEY
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
from .config import Settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NullTracer:
|
|
10
|
+
"""No-op: when Langfuse keys are absent — zero noise, zero active dependency."""
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def span(self, name: str, **meta: object) -> Iterator[None]:
|
|
14
|
+
yield
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LangfuseTracer:
|
|
18
|
+
def __init__(self, settings: Settings) -> None:
|
|
19
|
+
from langfuse import Langfuse # deferred import: only when actually used
|
|
20
|
+
|
|
21
|
+
self._client = Langfuse(
|
|
22
|
+
public_key=settings.langfuse_public_key,
|
|
23
|
+
secret_key=settings.langfuse_secret_key,
|
|
24
|
+
host=settings.langfuse_host,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def span(self, name: str, **meta: object) -> Iterator[object]:
|
|
29
|
+
with self._client.start_as_current_observation(
|
|
30
|
+
name=name, metadata=dict(meta)
|
|
31
|
+
) as span:
|
|
32
|
+
yield span
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_tracer(settings: Settings) -> NullTracer | LangfuseTracer:
|
|
36
|
+
if settings.langfuse_public_key and settings.langfuse_secret_key:
|
|
37
|
+
try:
|
|
38
|
+
return LangfuseTracer(settings)
|
|
39
|
+
except Exception: # noqa: BLE001 - observability must never break a run
|
|
40
|
+
return NullTracer()
|
|
41
|
+
return NullTracer()
|
buildwright/py.typed
ADDED
|
File without changes
|
buildwright/registry.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from pydantic_ai.models import Model
|
|
10
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
11
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelSpec(BaseModel):
|
|
15
|
+
"""An entry of models.yaml: a model reachable via an OpenAI-compatible endpoint."""
|
|
16
|
+
|
|
17
|
+
id: str
|
|
18
|
+
label: str
|
|
19
|
+
kind: Literal["local", "remote"]
|
|
20
|
+
base_url: str
|
|
21
|
+
model: str | None = None # "wire" name; defaults to the id without the prefix
|
|
22
|
+
api_key_env: str | None = None
|
|
23
|
+
context_window: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ModelRegistry:
|
|
27
|
+
def __init__(self, specs: list[ModelSpec]) -> None:
|
|
28
|
+
self._specs = {s.id: s for s in specs}
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def load(cls, path: Path) -> ModelRegistry:
|
|
32
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
33
|
+
specs = [ModelSpec.model_validate(s) for s in data.get("models", [])]
|
|
34
|
+
return cls(specs)
|
|
35
|
+
|
|
36
|
+
def spec(self, model_id: str) -> ModelSpec:
|
|
37
|
+
try:
|
|
38
|
+
return self._specs[model_id]
|
|
39
|
+
except KeyError:
|
|
40
|
+
known = ", ".join(sorted(self._specs))
|
|
41
|
+
raise KeyError(f"unknown model id '{model_id}'. Known ids: {known}") from None
|
|
42
|
+
|
|
43
|
+
def list(self, kind: str | None = None) -> list[ModelSpec]:
|
|
44
|
+
return [s for s in self._specs.values() if kind is None or s.kind == kind]
|
|
45
|
+
|
|
46
|
+
def build(self, model_id: str) -> Model:
|
|
47
|
+
"""Build the PydanticAI model pointing at its endpoint. No network involved."""
|
|
48
|
+
s = self.spec(model_id)
|
|
49
|
+
api_key = "local" # ollama/llama.cpp ignore it; the client requires non-empty
|
|
50
|
+
if s.api_key_env:
|
|
51
|
+
api_key = os.environ.get(s.api_key_env, "")
|
|
52
|
+
if not api_key:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"model '{s.id}' requires env var {s.api_key_env} (not set)"
|
|
55
|
+
)
|
|
56
|
+
return OpenAIChatModel(
|
|
57
|
+
s.model or s.id.split("/", 1)[1],
|
|
58
|
+
provider=OpenAIProvider(base_url=s.base_url, api_key=api_key),
|
|
59
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: buildwright
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Per-agent model routing for AI agents: LangGraph orchestration, PydanticAI agents, LiteLLM-ready registry, Langfuse tracing
|
|
5
|
+
Project-URL: Homepage, https://github.com/jordimarsal/buildwright
|
|
6
|
+
Project-URL: Repository, https://github.com/jordimarsal/buildwright
|
|
7
|
+
Project-URL: Issues, https://github.com/jordimarsal/buildwright/issues
|
|
8
|
+
Author: Jordi Marçal Poy
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,langfuse,langgraph,litellm,llm,model-routing,pydantic-ai,spec-driven-development
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: langfuse>=3.0
|
|
22
|
+
Requires-Dist: langgraph>=1.0
|
|
23
|
+
Requires-Dist: pydantic-ai-slim[openai]>=1.0
|
|
24
|
+
Requires-Dist: pydantic-settings>=2.4
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# buildwright
|
|
29
|
+
|
|
30
|
+
*The craftsman who builds per the spec* — one model per agent: mechanical steps
|
|
31
|
+
go to the **local** model (cheap, fast), important decisions to the **remote**
|
|
32
|
+
one (strong). The role→model mapping is configuration, not code — switching
|
|
33
|
+
models touches zero lines of pipeline.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv tool install buildwright # once published to PyPI
|
|
39
|
+
# or for development:
|
|
40
|
+
uv sync
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Division of roles (one tool, one job)
|
|
44
|
+
|
|
45
|
+
| Piece | Role | Where it lives |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| **LangGraph** | the ORDER of the steps (graph, state, retries) | `graph.py` |
|
|
48
|
+
| **PydanticAI** | EACH STEP (agent with structured output) | `agents.py` |
|
|
49
|
+
| **LiteLLM** | WHO serves the model (optional proxy, fallbacks) | `litellm/config.yaml` |
|
|
50
|
+
| **Langfuse** | WHETHER IT WORKED (traces, scores) | `observability.py` |
|
|
51
|
+
|
|
52
|
+
## The pipeline
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
START → plan (local) → execute (remote) → review (local) → END
|
|
56
|
+
Plan struct. final answer Review struct.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each node is a PydanticAI agent; the model is decided by the **role** in
|
|
60
|
+
`Settings` (`BW_ROLE_*`), resolved against the `models.yaml` registry.
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv sync
|
|
66
|
+
cp .env.example .env # adjust BW_ROLE_* and keys
|
|
67
|
+
|
|
68
|
+
uv run buildwright models # list local and remote models
|
|
69
|
+
uv run buildwright roles # role -> model mapping
|
|
70
|
+
uv run buildwright ask "write a snake game in python with pygame"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
With no endpoints available, `ask` fails naming the exact model and the missing
|
|
74
|
+
env var.
|
|
75
|
+
|
|
76
|
+
## Local and remote models
|
|
77
|
+
|
|
78
|
+
The whole registry speaks **OpenAI-compatible** (`models.yaml`):
|
|
79
|
+
|
|
80
|
+
- **local/qwen3** → Ollama at `localhost:11434/v1`
|
|
81
|
+
- **local/llamacpp** → `llama-server` at `localhost:8080/v1` (your GGUF)
|
|
82
|
+
- **remote/gpt-4o-mini, gpt-4o** → OpenAI (key via `OPENAI_API_KEY`)
|
|
83
|
+
- **remote/claude-sonnet** → Anthropic's OpenAI-compat layer
|
|
84
|
+
- **litellm/*** → via the LiteLLM proxy (see `docker-compose.yml`)
|
|
85
|
+
|
|
86
|
+
Registry resolution: `BW_MODELS_FILE` > project-local `./models.yaml` > the
|
|
87
|
+
`models.yaml` bundled with the package. Adding a model = adding 5 lines.
|
|
88
|
+
Reassigning one to an agent = changing a `BW_ROLE_*` in `.env`.
|
|
89
|
+
|
|
90
|
+
## Optional full stack (LiteLLM + Langfuse + Ollama)
|
|
91
|
+
|
|
92
|
+
With Docker, `docker compose up -d` brings up LiteLLM (routing/fallbacks),
|
|
93
|
+
Langfuse (traces) and Ollama. Langfuse keys show up in its admin UI; copy them
|
|
94
|
+
to `.env` and tracing turns itself on (without keys, the tracer is a no-op).
|
|
95
|
+
|
|
96
|
+
## Publishing to PyPI
|
|
97
|
+
|
|
98
|
+
Trusted publishing — no API tokens:
|
|
99
|
+
|
|
100
|
+
1. Create `github.com/jordimarsal/buildwright` and push `main`.
|
|
101
|
+
2. On pypi.org: *Add a pending publisher* → owner `jordimarsal`, repo
|
|
102
|
+
`buildwright`, workflow `release.yml`, environment `pypi`.
|
|
103
|
+
3. `git tag v0.1.0 && git push --tags` → the workflow runs `uv build` and
|
|
104
|
+
publishes.
|
|
105
|
+
|
|
106
|
+
## Tests
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
uv run pytest # offline: pydantic-ai's TestModel, zero network
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Anti-mess rules
|
|
113
|
+
|
|
114
|
+
1. Everything speaks OpenAI protocol; PydanticAI never imports litellm.
|
|
115
|
+
2. One agent = one node = one job. No agents inside agents.
|
|
116
|
+
3. Tracing registered exactly once (`observability.py`); never scattered.
|
|
117
|
+
4. Offline evals in CI rule; Langfuse is production monitoring.
|
|
118
|
+
5. Each piece joins when it has a real trigger (fallbacks→LiteLLM,
|
|
119
|
+
non-linear flow→LangGraph is already in).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
buildwright/__init__.py,sha256=AWzRjCUL2lAM4A7UJMXDUBT0c46zksXEuQVq2DlHnEc,98
|
|
2
|
+
buildwright/agents.py,sha256=Nq-j27tpllObBR1NQeaK1Bn0K3K1zQ8JrEnkpKDbADM,2332
|
|
3
|
+
buildwright/cli.py,sha256=xine3ba0OUlll4tXkWh2b9g3TFQ9WlJ_SssSEjz2bfo,2754
|
|
4
|
+
buildwright/config.py,sha256=fqr-qFkg1hB9UqqMWvDOSEgT1HTAha5rLTXOT0ZRiC0,1522
|
|
5
|
+
buildwright/graph.py,sha256=hQgleAy5nrIa7oCDhTmoXAon1KAi5_7Sb6PopR7cP6s,1612
|
|
6
|
+
buildwright/models.yaml,sha256=gNsYK83cs8eRLp6dquJO44UtDKDDXWKt6jJJL5H875k,1270
|
|
7
|
+
buildwright/observability.py,sha256=ZG8lRuENpu3PTwes1vhk1oIXCLE3_nZ20A-_nn1FPks,1259
|
|
8
|
+
buildwright/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
buildwright/registry.py,sha256=FkazWd80pjpad79O96YgY9tas9GmALacDMMppdOj_vk,2091
|
|
10
|
+
buildwright-0.1.0.dist-info/METADATA,sha256=yJE4rSPw5lpMb-MApD1RS9XYMLBa3RO9wMniZbhy9NM,4479
|
|
11
|
+
buildwright-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
buildwright-0.1.0.dist-info/entry_points.txt,sha256=izS4KVdhp6UAp5XCmA9M0QA8JKIkNAaCgFDzH0_7IpA,53
|
|
13
|
+
buildwright-0.1.0.dist-info/licenses/LICENSE,sha256=eMgpubNpl0LA4mylx__ss8lgutn16acZxLGnuGclGuE,1074
|
|
14
|
+
buildwright-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordi Marçal Poy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|