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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jobs:
run: ruff check src/ tests/
- name: Ruff format check
run: ruff format --check src/ tests/
- name: Marketing version sync
# Fails if README/landing/OG-card versions drift from pyproject —
# the served social card once sat 3 minors behind silently.
run: python scripts/sync_marketing_version.py --check

test:
runs-on: ubuntu-latest
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ An open-source project from **[NichevLabs](https://nichevlabs.com)**.

**Multi-agent orchestration in plain Python.** Build agent graphs, compose pipelines with `|`, deploy with one command. No DSL, no compile step, no paid debugger. Works with OpenAI, Anthropic, Gemini, and Ollama.

> 🎉 **selectools is 1.0 — stable.** The public API is frozen; `@stable` symbols carry a 2-minor compatibility promise. Python 3.10+. Latest: [v1.2](#whats-new-in-v12) — four-stage guardrails, native structured output, and observability events.
> 🎉 **selectools is 1.0 — stable.** The public API is frozen; `@stable` symbols carry a 2-minor compatibility promise. Python 3.10+. Latest: [v1.3](#whats-new-in-v13) — tested public streaming contracts for structured-output chat agents.

### 3 Ways to Build

Expand All @@ -32,6 +32,17 @@ result = AgentGraph.chain(planner, writer, reviewer).run("Write a blog post")
# selectools serve agent.yaml
```

## What's New in v1.3

### v1.3.0 — Streaming Contracts

Makes `final_turn_only` structured output safe to rely on in streaming chat agents. Behaviors that integrators previously had to learn from the source are now **documented, tested public contracts** — enforced on every consumption surface: `astream()` chunks, the `stream_handler` callback, and the `selectools.serve` SSE stream.

- **The synthesis turn never streams** — its JSON arrives only on the terminal `AgentResult` (`.content` / `.parsed`), never as chunks or through `stream_handler`. `StreamChunk(event="structured_synthesis_start")` fires once when the synthesis turn begins so clients can show a pending state.
- **`single_pass` no longer streams the structured answer** (*Changed*, `@beta` surface) — content is suppressed since it's the JSON envelope by construction; tool-call chunks still stream for activity display. Read the answer from `AgentResult.parsed`.
- **New `StreamChunk(event="structured_reuse")`** — when the converged loop answer validates and is reused, the just-streamed chunks WERE the answer; this signal lets a chat surface convert or retract the bubble. The serve SSE endpoint forwards event chunks as `{"type": "event", ...}`.
- **`should_finalize` message contract** — the predicate's `messages` view is documented: TOOL messages always carry `tool_name`, and `tool_result` holds the result text for successful runs while staying `None` for errors/denials (the supported failure discriminator).

## What's New in v1.2

### v1.2.0 — Tool-Boundary Guardrails Complete & Structured Synthesis Follow-ups
Expand Down
1 change: 1 addition & 0 deletions docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ Every feature MUST update all of these:
- CHANGELOG.md lives at repo root — must be copied into `docs/` before build
- Examples gallery (`landing/examples/index.html`) must be regenerated when examples are added or edited: `python scripts/build_examples_gallery.py > landing/examples/index.html`
- Hardcoded counts (model count, test count, example count) appear across many files — audit all
- **Marketing version strings** (README banner, landing status bar/footer, OG-card badges) are synced from `pyproject.toml` by `scripts/sync_marketing_version.py`. CI runs it with `--check` and fails on drift; the release flow (`release.py`) runs the write mode. After it bumps the OG **SVG**, re-render the PNG: `uv run --with playwright python scripts/render_og_image.py`.
- `docs/ARCHITECTURE.md` must be updated when adding new components
2 changes: 1 addition & 1 deletion docs/assets/og-card.html
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
<path d="M32.5 62C45.5 62 44.5 56.2 44.5 56.2L44.48 50.2H32V48.5H51.5S62 49.8 62 32.5S53 16 53 16H47.5V22.2S47.8 31 38.8 31H25.7S17.5 30.9 17.5 38.8V54.7S16.3 62 32.5 62ZM39.5 56.5A3 3 0 1 1 39.5 50.5A3 3 0 0 1 39.5 56.5Z" fill="url(#pyYellow)"/>
</svg>
<span class="title">selectools</span>
<span style="margin-left:14px;padding:4px 12px;border:1.5px solid #22d3ee;border-radius:999px;color:#22d3ee;font-size:15px;font-weight:700;letter-spacing:.02em;vertical-align:middle">v1.2 · STABLE</span>
<span style="margin-left:14px;padding:4px 12px;border:1.5px solid #22d3ee;border-radius:999px;color:#22d3ee;font-size:15px;font-weight:700;letter-spacing:.02em;vertical-align:middle">v1.3 · STABLE</span>
</div>

<div class="tagline">AI agents that are just Python.</div>
Expand Down
2 changes: 1 addition & 1 deletion docs/assets/og-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified landing/assets/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion landing/assets/og-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4461,7 +4461,7 @@ <h1>AI agents that are <span class="grad">just Python.</span></h1>
<div class="status-item"><span class="status-val">115</span> examples</div>
<div class="status-item"><span class="status-val">50</span> evaluators</div>
<div class="status-item"><span class="status-val">9</span> providers</div>
<div class="status-item"><span class="status-val">v1.2.0</span></div>
<div class="status-item"><span class="status-val">v1.3.0</span></div>
</div>
</div>

Expand Down Expand Up @@ -5840,7 +5840,7 @@ <h2>Type a question. Or browse the docs.</h2>
├── <a href="https://pypi.org/project/selectools/">pypi/</a> <span class="footer-term__cmt"># pip install selectools</span>
└── <a href="CHANGELOG/">changelog/</a> <span class="footer-term__cmt"># what shipped, what changed</span>

<span class="footer-term__cmt"># selectools v1.2.0 &middot; Apache-2.0 &middot; <span class="footer-term__nichev">made by <a href="https://nichevlabs.com">NichevLabs</a></span></span>
<span class="footer-term__cmt"># selectools v1.3.0 &middot; Apache-2.0 &middot; <span class="footer-term__nichev">made by <a href="https://nichevlabs.com">NichevLabs</a></span></span>
<span class="footer-term__cmt"># for developers who ship</span></div>
<div class="footer-term__final">
<span class="pr">$</span>
Expand Down
16 changes: 16 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ def update_changelog(self, version: str, message: Optional[str] = None) -> None:
self.changelog_path.write_text("\n".join(lines))
print(f"✓ Updated CHANGELOG.md with version {version}")

def sync_marketing_version(self) -> None:
"""Bump README banner + landing + OG-card version strings to match.

The CI ``sync_marketing_version.py --check`` step fails the build on
drift, so this keeps a release from tripping its own gate. The OG
*PNG* still needs a manual re-render (``render_og_image.py``); the
script prints that reminder.
"""
script = self.repo_root / "scripts" / "sync_marketing_version.py"
if not script.exists():
print("⚠ sync_marketing_version.py not found, skipping marketing sync")
return
subprocess.run([sys.executable, str(script)], cwd=self.repo_root, check=True)
print("✓ Synced marketing-surface version strings")

def run_command(self, cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run a shell command"""
print(f" Running: {' '.join(cmd)}")
Expand Down Expand Up @@ -206,6 +221,7 @@ def release(
try:
self.update_version(new_version)
self.update_changelog(new_version, message)
self.sync_marketing_version()
self.git_commit(new_version, message)
self.git_tag(new_version)
self.git_push(new_version)
Expand Down
50 changes: 50 additions & 0 deletions scripts/render_og_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Rasterize the landing OG social card SVG to PNG at 1200x630.

The served social card (``landing/assets/og-image.png``) is a raster of
``landing/assets/og-image.svg``. Run this after the SVG changes (e.g. after
``sync_marketing_version.py`` bumps the version footer) so the PNG link
preview matches.

Headless Chromium is used deliberately (not librsvg/cairosvg) so the font
fallback matches the design; ad-hoc rasterizers substitute fonts and shift
the layout. Requires the ``playwright`` package + its chromium browser:

uv run --with playwright python scripts/render_og_image.py
# first time: uv run --with playwright playwright install chromium
"""

from __future__ import annotations

from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
SVG = REPO_ROOT / "landing/assets/og-image.svg"
PNG = REPO_ROOT / "landing/assets/og-image.png"
WIDTH, HEIGHT = 1200, 630


def main() -> int:
try:
from playwright.sync_api import sync_playwright
except ImportError:
print(
"playwright not installed. Run:\n"
" uv run --with playwright python scripts/render_og_image.py\n"
" (first time also: ... playwright install chromium)"
)
return 1

with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}, device_scale_factor=1)
page.goto(SVG.as_uri())
page.wait_for_timeout(500)
page.screenshot(path=str(PNG))
browser.close()
print(f"Rendered {PNG.relative_to(REPO_ROOT)} ({WIDTH}x{HEIGHT}).")
return 0


if __name__ == "__main__":
raise SystemExit(main())
165 changes: 165 additions & 0 deletions scripts/sync_marketing_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Keep marketing-surface version strings in sync with pyproject.toml.

The docs site, README banner, landing page, and OG social cards each carry
the current version in prose/markup. Three releases in one day (1.1 -> 1.3)
showed these drift silently: the served OG card sat at v0.20.1 for months
because nothing tied it to the package version.

This script is the single source of truth binding. Run modes:

python scripts/sync_marketing_version.py # rewrite drifted files
python scripts/sync_marketing_version.py --check # exit 1 on any drift

``--check`` runs in CI so a stale marketing version fails the build instead
of shipping. Write mode is called from the release flow.

NOTE: the OG *PNG* (landing/assets/og-image.png) is a raster derived from
landing/assets/og-image.svg. This script updates the SVG text and reminds
you to re-render the PNG; it does not rasterize (to avoid font-fallback
drift from an ad-hoc CI renderer). Re-render with the pinned tool:

scripts/render_og_image.py # headless Chromium, 1200x630
"""

from __future__ import annotations

import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List

REPO_ROOT = Path(__file__).resolve().parent.parent


def read_version() -> str:
"""The one source of truth: pyproject.toml ``version``."""
content = (REPO_ROOT / "pyproject.toml").read_text()
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if not match:
raise SystemExit("could not find version in pyproject.toml")
return match.group(1)


def _minor(version: str) -> str:
"""``1.3.0`` -> ``1.3`` for the badges/banner that use the minor form."""
parts = version.split(".")
return ".".join(parts[:2])


@dataclass
class Rule:
"""A single file + a regex whose one match holds the version token."""

path: str
pattern: "re.Pattern[str]"
# (version) -> the correct full matched string
replacement: Callable[[str], str]
required: bool = True


# Each rule pins ONE occurrence. A drift or a count change (someone adds a
# second version mention) is surfaced rather than silently half-fixed.
RULES: List[Rule] = [
# README top banner: "Latest: [v1.3](#whats-new-in-v13) ..."
Rule(
path="README.md",
pattern=re.compile(r"Latest: \[v(\d+\.\d+)\]\(#whats-new-in-v\d+\)"),
replacement=lambda v: (
f"Latest: [v{_minor(v)}](#whats-new-in-v{_minor(v).replace('.', '')})"
),
),
# Landing status bar: <span class="status-val">v1.3.0</span>
Rule(
path="landing/index.html",
pattern=re.compile(r'<span class="status-val">v(\d+\.\d+\.\d+)</span>'),
replacement=lambda v: f'<span class="status-val">v{v}</span>',
),
# Landing footer terminal comment: "# selectools v1.3.0 &middot;"
Rule(
path="landing/index.html",
pattern=re.compile(r"# selectools v(\d+\.\d+\.\d+) &middot;"),
replacement=lambda v: f"# selectools v{v} &middot;",
),
# Landing OG SVG footer: "v1.3.0 · Apache-2.0"
Rule(
path="landing/assets/og-image.svg",
pattern=re.compile(r"v(\d+\.\d+\.\d+) · Apache-2\.0"),
replacement=lambda v: f"v{v} · Apache-2.0",
),
# Docs OG card badge: "v1.3 · STABLE"
Rule(
path="docs/assets/og-card.html",
pattern=re.compile(r"v(\d+\.\d+) · STABLE"),
replacement=lambda v: f"v{_minor(v)} · STABLE",
),
# Docs OG SVG badge: "v1.3 &#183; STABLE"
Rule(
path="docs/assets/og-image.svg",
pattern=re.compile(r"v(\d+\.\d+) &#183; STABLE"),
replacement=lambda v: f"v{_minor(v)} &#183; STABLE",
),
]


def apply_rule(rule: Rule, version: str, *, check: bool) -> List[str]:
"""Return a list of human-readable drift messages (empty when in sync)."""
path = REPO_ROOT / rule.path
if not path.exists():
return [] if not rule.required else [f"{rule.path}: missing"]
text = path.read_text()
pat = rule.pattern
matches = pat.findall(text)
if not matches:
return [f"{rule.path}: no version token matched {pat.pattern!r}"]
if len(matches) > 1:
return [f"{rule.path}: {len(matches)} version tokens matched (expected 1)"]
correct = rule.replacement(version)
if pat.search(text).group(0) == correct:
return []
if check:
return [f"{rule.path}: found v{matches[0]}, expected {version}"]
path.write_text(pat.sub(correct, text, count=1))
print(f" updated {rule.path} -> {version}")
return []


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="report drift and exit 1 without writing (for CI)",
)
args = parser.parse_args()

version = read_version()
drift: List[str] = []
for rule in RULES:
drift.extend(apply_rule(rule, version, check=args.check))

if drift:
header = "Marketing version drift" if args.check else "Could not sync"
print(f"{header} (pyproject = {version}):", file=sys.stderr)
for msg in drift:
print(f" - {msg}", file=sys.stderr)
if args.check:
print(
"\nRun `python scripts/sync_marketing_version.py` to fix, then "
"re-render the OG PNG with `python scripts/render_og_image.py`.",
file=sys.stderr,
)
return 1

if not args.check:
print(f"Marketing surfaces in sync at v{version}.")
print("Reminder: re-render the OG PNG with `python scripts/render_og_image.py`.")
else:
print(f"Marketing surfaces in sync at v{version}.")
return 0


if __name__ == "__main__":
raise SystemExit(main())