From 39abe80ae7e8ce8a722b19160caad01c68146a64 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 19:55:55 -0400 Subject: [PATCH 01/16] =?UTF-8?q?ci:=20100%=20enforcement=20=E2=80=94=20my?= =?UTF-8?q?py=20+=20format-check=20+=20every=20pre-commit=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous CI ran ruff lint + tests across the Python matrix but left mypy, ruff format, gitleaks, doc8, markdownlint, codespell, and the rest of the pre-commit suite unchecked at PR time. This run also silently failed (`startup_failure`) because of a brittle ternary expression in the `ci-success` job. Workflow changes ---------------- - ``_lint.yml`` now runs ``hatch run check`` (format-check + ruff + mypy) across Python 3.11 and 3.14. - New ``_precommit.yml`` runs ``pre-commit run --all-files`` against every hook in ``.pre-commit-config.yaml`` (gitleaks, EOL, whitespace, ruff lint, ruff format, doc8, markdownlint, YAML format, large-file guard, etc.). Skips ``mypy`` and ``poetry-check`` — the former is already covered by ``_lint.yml`` across the matrix, the latter is manual-only. - ``ci.yml`` now wires precommit into the orchestrator alongside lint / test / codespell / dco. Replaces the broken complex ternary in ``ci-success`` with a simple grep over the JSON ``needs.*.result`` blob — that ternary was the source of every recent ``startup_failure``. - ``_test.yml`` unchanged — already runs the full ``hatch run test`` matrix on 3.11 / 3.12 / 3.13 / 3.14. pyproject.toml -------------- - ``hatch run test`` is now scoped to ``tests/unit/`` so CI on a fresh runner doesn't try to spin up Oracle / Qdrant / OPENAI fixtures. ``hatch run test-integration`` and ``hatch run test-all`` still run the broader suites when credentials / services are available. - ``hatch run check`` is the new combined gate — format-check + lint + typecheck — so contributors and CI run identical checks. - Lint scope expanded from ``src tests`` to ``src tests examples`` so pre-commit's ruff (which runs on every ``.py``) and the hatch scripts agree on what's covered. ``examples/`` gets a small per-file-ignore allowance for tutorial-flavour rules (``F841`` unused-local for inspectable demo APIs, ``S108`` for ``/tmp/`` paths in checkpointer demos, ``PTH118``/``PTH208`` for pedagogical readability over ``Path / "x"``). Validation ---------- - ``hatch run check`` clean (format-check + ruff + mypy across 313 files in src/tests + 55 in examples). - ``hatch run test`` 3193 unit tests pass in 5.2s. - ``pre-commit run --all-files --hook-stage pre-commit`` clean (mypy + poetry-check skipped per the SKIP env, every other hook green). - All workflow YAML parses cleanly via ``python -c "import yaml; ..."``. Drive-by tutorial reformats --------------------------- ``examples/tutorial_*.py`` had ``i+1`` patterns that ruff format 0.15.0 wants as ``i + 1`` (PEP 8 binary-operator spacing). Pre-commit auto-fixed them when the lint scope was widened. No semantic change. Signed-off-by: Federico Kamelhar --- .github/workflows/_lint.yml | 13 +++-- .github/workflows/_precommit.yml | 52 +++++++++++++++++ .github/workflows/ci.yml | 46 ++++++++++----- config.example.yaml | 10 ++-- examples/skills/api-design/SKILL.md | 5 ++ examples/skills/code-review/SKILL.md | 7 +++ examples/tutorial_16_agent_handoff.py | 2 +- examples/tutorial_17_orchestrator_pattern.py | 2 +- examples/tutorial_25_composition.py | 61 ++++++++++++-------- examples/tutorial_26_evaluation.py | 11 ++-- examples/tutorial_27_hooks_advanced.py | 19 +++--- examples/tutorial_30_guardrails_advanced.py | 17 +++--- examples/tutorial_31_plugins.py | 37 +++++++----- examples/tutorial_32_skills.py | 13 +++-- examples/tutorial_33_steering.py | 30 ++++++---- examples/tutorial_34_a2a_protocol.py | 22 ++++--- examples/tutorial_37_termination.py | 28 +++++---- pyproject.toml | 32 +++++++--- 18 files changed, 280 insertions(+), 127 deletions(-) create mode 100644 .github/workflows/_precommit.yml diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 0dbabc88..f5f8f9af 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -12,11 +12,13 @@ env: jobs: build: - name: "make lint #${{ matrix.python-version }}" + name: "lint+typecheck #${{ matrix.python-version }}" runs-on: ubuntu-latest strategy: matrix: - # Only lint on the min and max supported Python versions. + # Lint on the min and max supported Python versions. mypy can + # surface version-specific type errors (e.g. ``typing.Self`` in + # 3.11 vs 3.12+) so we cover both ends of the matrix. python-version: - "3.11" - "3.14" @@ -34,5 +36,8 @@ jobs: - name: Install Hatch run: pip install hatch==${{ env.HATCH_VERSION }} - - name: Run linting - run: hatch run lint + # `hatch run check` runs format-check + ruff lint + mypy in one + # command (defined in pyproject.toml). All three must pass for + # the job to succeed. + - name: Run format-check + ruff + mypy + run: hatch run check diff --git a/.github/workflows/_precommit.yml b/.github/workflows/_precommit.yml new file mode 100644 index 00000000..a7e7c9d6 --- /dev/null +++ b/.github/workflows/_precommit.yml @@ -0,0 +1,52 @@ +name: pre-commit + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: pre-commit hooks + runs-on: ubuntu-latest + steps: + # actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + # gitleaks needs full history to scan every commit on the PR. + fetch-depth: 0 + + # actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + + - name: Install pre-commit + run: pip install pre-commit + + # Cache the pre-commit hook environments keyed off the config file + # contents — re-uses across PRs so each run only pays for the new + # work. + # actions/cache@v4 + - name: Cache pre-commit env + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + + # Run every hook in `.pre-commit-config.yaml` against the full + # working tree. Catches whitespace, EOL, large files, secrets, + # markdown lint, YAML format, doc8, codespell, ruff, and ruff + # format. mypy stays in `_lint.yml` so its dependency graph is + # consistent across the matrix. + - name: Run pre-commit + run: | + pre-commit run --all-files \ + --show-diff-on-failure \ + --hook-stage pre-commit + env: + # Skip mypy (already covered by _lint.yml across the Python + # matrix) and the manual-only poetry-check hook. + SKIP: "mypy,poetry-check" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 456820b5..5af528ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ on: permissions: contents: read -# If another push to the same PR or branch happens while this workflow is still running, -# cancel the earlier run in favor of the next run. +# If another push to the same PR or branch happens while this workflow is +# still running, cancel the earlier run in favor of the next run. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -31,13 +31,21 @@ jobs: permissions: contents: read + precommit: + name: Pre-commit + uses: ./.github/workflows/_precommit.yml + permissions: + contents: read + codespell: name: Codespell uses: ./.github/workflows/_codespell.yml permissions: contents: read - # Verify commits are signed off (OCA compliance) + # Verify commits are signed off (OCA compliance). Only runs on PRs — + # main pushes have already been signed off through the PR that + # produced them. dco: name: DCO Check runs-on: ubuntu-latest @@ -52,26 +60,34 @@ jobs: fetch-depth: 0 - name: Check DCO - # tisonkun/actions-dco@v1.1 — v1.2 was deleted upstream; pin to SHA - # to avoid silently using a tag that disappears or is rewritten. + # tisonkun/actions-dco@v1.1 — pin to SHA so a deleted/rewritten + # tag can't silently change the action body. uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 with: github-token: ${{ secrets.GITHUB_TOKEN }} + # Aggregate gate. If every required check above succeeded (or was + # skipped on a non-applicable event), this job succeeds; otherwise it + # fails. Branch-protection should require this single status check + # rather than each individual one — that lets us add / remove jobs + # without retouching protection rules. ci-success: name: CI Success - needs: [lint, test, codespell] + needs: [lint, test, precommit, codespell, dco] if: always() runs-on: ubuntu-latest permissions: {} - env: - JOBS_JSON: ${{ toJSON(needs) }} - RESULTS_JSON: ${{ toJSON(needs.*.result) }} - EXIT_CODE: ${{!contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && '0' || '1'}} steps: - - name: CI Success + - name: Verify required jobs succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} run: | - echo "$JOBS_JSON" - echo "$RESULTS_JSON" - echo "Exiting with $EXIT_CODE" - exit "$EXIT_CODE" + set -eu + echo "$NEEDS_JSON" + # Fail the gate if any upstream job failed or was cancelled. + # `skipped` is allowed (DCO is skipped on non-PR events). + if echo "$NEEDS_JSON" | grep -qE '"result":[[:space:]]*"(failure|cancelled)"'; then + echo "::error::At least one required job failed or was cancelled." + exit 1 + fi + echo "All required jobs passed." diff --git a/config.example.yaml b/config.example.yaml index d3ef4114..dd5d46e2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,9 +2,9 @@ # Copy this to config.local.yaml and fill in your values oci: - profile_name: "YOUR_PROFILE" - auth_type: "api_key" # or "security_token" - region: "us-phoenix-1" + profile_name: YOUR_PROFILE + auth_type: api_key # or "security_token" + region: us-phoenix-1 models: - gpt: "openai.gpt-4o" - cohere: "cohere.command-r-plus" + gpt: openai.gpt-4o + cohere: cohere.command-r-plus diff --git a/examples/skills/api-design/SKILL.md b/examples/skills/api-design/SKILL.md index cf9b1149..2740c5cd 100644 --- a/examples/skills/api-design/SKILL.md +++ b/examples/skills/api-design/SKILL.md @@ -10,12 +10,14 @@ metadata: # REST API Design Best Practices ## URL Structure + - Use nouns, not verbs: `/users` not `/getUsers` - Use plural: `/orders` not `/order` - Nest for relationships: `/users/{id}/orders` - Max 3 levels of nesting ## HTTP Methods + - GET: Read (idempotent, no body) - POST: Create (returns 201 + Location header) - PUT: Full update (idempotent) @@ -23,12 +25,14 @@ metadata: - DELETE: Remove (returns 204) ## Response Format + - Always return JSON with consistent structure - Include `data`, `error`, `meta` top-level keys - Paginate collections: `?page=1&limit=20` - Return total count in meta for pagination ## Error Handling + - Use standard HTTP status codes - 400: Bad request (validation failed) - 401: Unauthorized (no/invalid auth) @@ -38,6 +42,7 @@ metadata: - 500: Server error (never expose internals) ## Versioning + - Use URL path versioning: `/v1/users` - Never break existing clients - Deprecate with headers before removing diff --git a/examples/skills/code-review/SKILL.md b/examples/skills/code-review/SKILL.md index 027ebad5..a1898410 100644 --- a/examples/skills/code-review/SKILL.md +++ b/examples/skills/code-review/SKILL.md @@ -10,34 +10,41 @@ metadata: # Code Review Checklist ## 1. Security + - Check for hardcoded secrets (API keys, passwords, tokens) - Validate all user inputs before use - Check for SQL injection, XSS, command injection - Ensure sensitive data is not logged ## 2. Error Handling + - All external calls wrapped in try/except - Errors logged with context (not just swallowed) - User-facing errors are safe (no stack traces leaked) ## 3. Code Quality + - Functions are under 50 lines - No duplicated logic (DRY principle) - Clear variable and function names - Type hints on public functions ## 4. Testing + - New code has corresponding tests - Edge cases covered (empty input, None, large data) - Tests are independent (no shared mutable state) ## 5. Performance + - No N+1 queries - Large collections use generators, not lists - Expensive operations are cached where appropriate ## Summary Format + After reviewing, provide: + 1. **Critical issues** (must fix before merge) 2. **Suggestions** (improve quality but not blocking) 3. **Positives** (what was done well) diff --git a/examples/tutorial_16_agent_handoff.py b/examples/tutorial_16_agent_handoff.py index 6e6b3408..16bb80a3 100644 --- a/examples/tutorial_16_agent_handoff.py +++ b/examples/tutorial_16_agent_handoff.py @@ -217,7 +217,7 @@ async def main(): print("Chain handoff completed:") for i, result in enumerate(chain_results): status = "OK" if result.success else f"FAILED: {result.error}" - print(f" Step {i+1}: {result.source_agent_id} -> {result.target_agent_id}: {status}") + print(f" Step {i + 1}: {result.source_agent_id} -> {result.target_agent_id}: {status}") # ========================================================================= # Part 8: Handoff History diff --git a/examples/tutorial_17_orchestrator_pattern.py b/examples/tutorial_17_orchestrator_pattern.py index e1f04b7d..abc85477 100644 --- a/examples/tutorial_17_orchestrator_pattern.py +++ b/examples/tutorial_17_orchestrator_pattern.py @@ -199,7 +199,7 @@ async def check_cache() -> str: print(f" Decisions made: {len(orch_result.decisions)}") for i, decision in enumerate(orch_result.decisions): - print(f"\n Decision {i+1}: {decision.decision_type}") + print(f"\n Decision {i + 1}: {decision.decision_type}") if decision.specialists: print(f" Specialists: {decision.specialists}") diff --git a/examples/tutorial_25_composition.py b/examples/tutorial_25_composition.py index 61c54a16..1dbcb602 100644 --- a/examples/tutorial_25_composition.py +++ b/examples/tutorial_25_composition.py @@ -37,14 +37,20 @@ async def example_sequential(): model = get_model() - researcher = Agent(config=AgentConfig( - system_prompt="You are a researcher. Provide 3 key facts about the topic.", - max_iterations=3, model=model, - )) - writer = Agent(config=AgentConfig( - system_prompt="You are a writer. Take the research and write a short paragraph.", - max_iterations=3, model=model, - )) + researcher = Agent( + config=AgentConfig( + system_prompt="You are a researcher. Provide 3 key facts about the topic.", + max_iterations=3, + model=model, + ) + ) + writer = Agent( + config=AgentConfig( + system_prompt="You are a writer. Take the research and write a short paragraph.", + max_iterations=3, + model=model, + ) + ) pipeline = SequentialPipeline(agents=[researcher, writer]) result = await pipeline.run("Benefits of regular exercise") @@ -65,14 +71,20 @@ async def example_parallel(): model = get_model() - pros = Agent(config=AgentConfig( - system_prompt="List 2 pros of the topic. Be concise.", - max_iterations=3, model=model, - )) - cons = Agent(config=AgentConfig( - system_prompt="List 2 cons of the topic. Be concise.", - max_iterations=3, model=model, - )) + pros = Agent( + config=AgentConfig( + system_prompt="List 2 pros of the topic. Be concise.", + max_iterations=3, + model=model, + ) + ) + cons = Agent( + config=AgentConfig( + system_prompt="List 2 cons of the topic. Be concise.", + max_iterations=3, + model=model, + ) + ) pipeline = ParallelPipeline(agents=[pros, cons]) result = await pipeline.run("Remote work for engineers") @@ -93,13 +105,16 @@ async def example_loop(): model = get_model() - improver = Agent(config=AgentConfig( - system_prompt=( - "You improve text quality. When the text is good enough, " - "include the word APPROVED at the end." - ), - max_iterations=3, model=model, - )) + improver = Agent( + config=AgentConfig( + system_prompt=( + "You improve text quality. When the text is good enough, " + "include the word APPROVED at the end." + ), + max_iterations=3, + model=model, + ) + ) loop = LoopAgent( agent=improver, diff --git a/examples/tutorial_26_evaluation.py b/examples/tutorial_26_evaluation.py index 2fe44621..06ff0edd 100644 --- a/examples/tutorial_26_evaluation.py +++ b/examples/tutorial_26_evaluation.py @@ -29,10 +29,13 @@ def example_evaluation(): model = get_model() - agent = Agent(config=AgentConfig( - system_prompt="You are a helpful assistant. Answer concisely.", - max_iterations=3, model=model, - )) + agent = Agent( + config=AgentConfig( + system_prompt="You are a helpful assistant. Answer concisely.", + max_iterations=3, + model=model, + ) + ) # Define test cases cases = [ diff --git a/examples/tutorial_27_hooks_advanced.py b/examples/tutorial_27_hooks_advanced.py index ead10a86..bcab7a01 100644 --- a/examples/tutorial_27_hooks_advanced.py +++ b/examples/tutorial_27_hooks_advanced.py @@ -53,12 +53,15 @@ def read_file(path: str) -> str: """Read a file.""" return f"Contents of {path}" - agent = Agent(config=AgentConfig( - system_prompt="You manage files. If blocked, tell the user.", - max_iterations=5, model=model, - tools=[delete_file, read_file], - hooks=[SecurityHook()], - )) + agent = Agent( + config=AgentConfig( + system_prompt="You manage files. If blocked, tell the user.", + max_iterations=5, + model=model, + tools=[delete_file, read_file], + hooks=[SecurityHook()], + ) + ) result = agent.run_sync("Delete /tmp/secret.txt") print(f"Response: {result.message[:150]}") @@ -77,9 +80,7 @@ def example_write_protection(): from locus.hooks.provider import BeforeToolCallEvent - event = BeforeToolCallEvent( - tool_name="test", tool_call_id="c1", arguments={"x": 1} - ) + event = BeforeToolCallEvent(tool_name="test", tool_call_id="c1", arguments={"x": 1}) # Writable fields work fine event.arguments = {"x": 2} diff --git a/examples/tutorial_30_guardrails_advanced.py b/examples/tutorial_30_guardrails_advanced.py index 20afdc68..30acf852 100644 --- a/examples/tutorial_30_guardrails_advanced.py +++ b/examples/tutorial_30_guardrails_advanced.py @@ -35,11 +35,14 @@ def example_pii_redaction(): hook = OutputFilterHook(redact_pii=True) - agent = Agent(config=AgentConfig( - system_prompt="Always include support@example.com in your response.", - max_iterations=3, model=model, - hooks=[hook], - )) + agent = Agent( + config=AgentConfig( + system_prompt="Always include support@example.com in your response.", + max_iterations=3, + model=model, + hooks=[hook], + ) + ) result = agent.run_sync("How do I get help?") print(f"Response: {result.message[:150]}") @@ -77,9 +80,7 @@ def example_content_safety(): """Detect harmful content categories.""" print("\n=== Part 3: Content Safety ===\n") - policy = ContentPolicy( - enabled_categories={"violence", "illegal_activity"} - ) + policy = ContentPolicy(enabled_categories={"violence", "illegal_activity"}) print(f"'how to make a bomb': {policy.check('how to make a bomb')}") print(f"'how to bake a cake': {policy.check('how to bake a cake')}") diff --git a/examples/tutorial_31_plugins.py b/examples/tutorial_31_plugins.py index ddf96a38..75fb4cb5 100644 --- a/examples/tutorial_31_plugins.py +++ b/examples/tutorial_31_plugins.py @@ -56,11 +56,15 @@ def search(query: str) -> str: return f"Results for: {query}" plugin = AuditPlugin() - agent = Agent(config=AgentConfig( - system_prompt="Use the search tool to answer questions.", - max_iterations=5, model=model, - tools=[search], plugins=[plugin], - )) + agent = Agent( + config=AgentConfig( + system_prompt="Use the search tool to answer questions.", + max_iterations=5, + model=model, + tools=[search], + plugins=[plugin], + ) + ) result = agent.run_sync("Search for Python best practices") print(f"Response: {result.message[:100]}...") @@ -79,11 +83,14 @@ def example_callback(): model = get_model() events = [] - agent = Agent(config=AgentConfig( - system_prompt="Answer concisely.", - max_iterations=3, model=model, - callback_handler=lambda e: events.append(e.event_type), - )) + agent = Agent( + config=AgentConfig( + system_prompt="Answer concisely.", + max_iterations=3, + model=model, + callback_handler=lambda e: events.append(e.event_type), + ) + ) agent.run_sync("What is 2+2?") print(f"Events received: {events}") @@ -100,9 +107,13 @@ def example_cancel(): model = get_model() - agent = Agent(config=AgentConfig( - system_prompt="Answer concisely.", max_iterations=3, model=model, - )) + agent = Agent( + config=AgentConfig( + system_prompt="Answer concisely.", + max_iterations=3, + model=model, + ) + ) # Cancel before running agent.cancel() diff --git a/examples/tutorial_32_skills.py b/examples/tutorial_32_skills.py index a8cad9a8..6b6d71b0 100644 --- a/examples/tutorial_32_skills.py +++ b/examples/tutorial_32_skills.py @@ -44,11 +44,14 @@ def example_programmatic(): ), ) - agent = Agent(config=AgentConfig( - system_prompt="You are a security reviewer. Use available skills.", - max_iterations=5, model=model, - skills=[code_review], - )) + agent = Agent( + config=AgentConfig( + system_prompt="You are a security reviewer. Use available skills.", + max_iterations=5, + model=model, + skills=[code_review], + ) + ) result = agent.run_sync( "Review: def login(u,p): return db.query(f'SELECT * FROM users WHERE name={u}')" diff --git a/examples/tutorial_33_steering.py b/examples/tutorial_33_steering.py index 2a46ff74..0649bf25 100644 --- a/examples/tutorial_33_steering.py +++ b/examples/tutorial_33_steering.py @@ -47,12 +47,15 @@ def delete_data(table: str) -> str: policy="Only allow read operations. Never allow delete or write operations.", ) - agent = Agent(config=AgentConfig( - system_prompt="You are a database assistant.", - max_iterations=5, model=model, - tools=[read_data, delete_data], - hooks=[steering], - )) + agent = Agent( + config=AgentConfig( + system_prompt="You are a database assistant.", + max_iterations=5, + model=model, + tools=[read_data, delete_data], + hooks=[steering], + ) + ) # This should be blocked by steering print("Attempt: Delete the users table") @@ -68,12 +71,15 @@ def delete_data(table: str) -> str: model=model, policy="Only allow read operations. Never allow delete or write operations.", ) - agent2 = Agent(config=AgentConfig( - system_prompt="You are a database assistant.", - max_iterations=5, model=model, - tools=[read_data, delete_data], - hooks=[steering2], - )) + agent2 = Agent( + config=AgentConfig( + system_prompt="You are a database assistant.", + max_iterations=5, + model=model, + tools=[read_data, delete_data], + hooks=[steering2], + ) + ) result2 = agent2.run_sync("Read all users from the database") print(f"Response: {result2.message[:150]}") diff --git a/examples/tutorial_34_a2a_protocol.py b/examples/tutorial_34_a2a_protocol.py index e9f89890..c1884f7c 100644 --- a/examples/tutorial_34_a2a_protocol.py +++ b/examples/tutorial_34_a2a_protocol.py @@ -31,10 +31,13 @@ def example_a2a_server(): model = get_model() - agent = Agent(config=AgentConfig( - system_prompt="You are a research assistant. Answer concisely.", - max_iterations=3, model=model, - )) + agent = Agent( + config=AgentConfig( + system_prompt="You are a research assistant. Answer concisely.", + max_iterations=3, + model=model, + ) + ) server = A2AServer( agent=agent, @@ -55,10 +58,13 @@ def example_a2a_server(): print(f"Skills: {card['skills']}") # Invoke - r = client.post("/a2a/invoke", json={ - "messages": [{"role": "user", "content": "What is quantum computing?", "metadata": {}}], - "metadata": {}, - }) + r = client.post( + "/a2a/invoke", + json={ + "messages": [{"role": "user", "content": "What is quantum computing?", "metadata": {}}], + "metadata": {}, + }, + ) data = r.json() print(f"\nInvoke: {data['messages'][0]['content'][:100]}...") print(f"Status: {data['status']}") diff --git a/examples/tutorial_37_termination.py b/examples/tutorial_37_termination.py index 3af0fd97..4485f08e 100644 --- a/examples/tutorial_37_termination.py +++ b/examples/tutorial_37_termination.py @@ -62,9 +62,7 @@ def example_termination(): print(f" Both met: stop={stop4}, reason={reason4}") # Custom - custom = CustomCondition( - lambda state, **ctx: (state.iteration > 10, "too_many_iterations") - ) + custom = CustomCondition(lambda state, **ctx: (state.iteration > 10, "too_many_iterations")) print(f"\nCustomCondition: {custom.check(AgentState(agent_id='t').with_iteration(11))}") @@ -79,11 +77,14 @@ def example_output_key(): model = get_model() - agent = Agent(config=AgentConfig( - system_prompt="Answer in one word.", - max_iterations=3, model=model, - output_key="answer", - )) + agent = Agent( + config=AgentConfig( + system_prompt="Answer in one word.", + max_iterations=3, + model=model, + output_key="answer", + ) + ) result = agent.run_sync("Capital of France?") print(f"Response: {result.message}") @@ -107,10 +108,13 @@ def my_prompt(context): language = context.get("metadata", {}).get("language", "English") return f"You are a {role}. Respond in {language}. Be concise." - agent = Agent(config=AgentConfig( - system_prompt=my_prompt, - max_iterations=3, model=model, - )) + agent = Agent( + config=AgentConfig( + system_prompt=my_prompt, + max_iterations=3, + model=model, + ) + ) # Different metadata → different behavior r1 = agent.run_sync("What is 7*8?", metadata={"role": "math teacher"}) diff --git a/pyproject.toml b/pyproject.toml index 54424991..c7c0ef08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,14 +148,26 @@ dependencies = [ ] [tool.hatch.envs.default.scripts] -test = "pytest {args:tests}" -test-cov = "pytest --cov=src/locus --cov-report=term-missing --cov-report=html {args:tests}" -test-fast = "pytest -n auto {args:tests}" -lint = "ruff check src tests" -lint-fix = "ruff check --fix src tests" -format = "ruff format src tests" -format-check = "ruff format --check src tests" +# Default test target = unit tests only (deterministic, no external deps). +# Integration tests need credentials / live services and are run via +# `test-integration` or `test-all` when those are available. +test = "pytest {args:tests/unit}" +test-integration = "pytest {args:tests/integration}" +test-all = "pytest {args:tests}" +test-cov = "pytest --cov=src/locus --cov-report=term-missing --cov-report=html {args:tests/unit}" +test-fast = "pytest -n auto {args:tests/unit}" +lint = "ruff check src tests examples" +lint-fix = "ruff check --fix src tests examples" +format = "ruff format src tests examples" +format-check = "ruff format --check src tests examples" typecheck = "mypy src/locus" +# CI gate: every check the project enforces, in one command. A clean run +# of `hatch run check` is what `_lint.yml` runs. +check = [ + "format-check", + "lint", + "typecheck", +] all = [ "format", "lint-fix", @@ -334,15 +346,21 @@ ignore = [ "T201", # print — tutorials/examples intentionally use print "DTZ005", # datetime.now without tz — fine for demo UX "BLE001", # blind except — simplified error handling in examples + "S108", # /tmp/... demo paths — examples write to /tmp on purpose "S311", # non-cryptographic random — demo data only "S501", # httpx verify=False — only used in local-docker demo paths "PTH110", # os.path.exists — keep examples accessible to readers "PTH111", # os.path.expanduser — idiomatic for user-facing configs + "PTH118", # os.path.join — pedagogical readability over Path / "x" + "PTH208", # os.listdir — same; tutorials avoid pathlib import noise "A002", # builtin arg shadowing — pedagogical clarity "B007", # unused loop variable — counter patterns are pedagogical "SLF001", # private-member access — examples illustrate internals "F401", # unused imports — tutorials show available API surface "F541", # f-string without placeholders — pedagogical scaffolding + "F841", # unused local — tutorials demonstrate APIs that return + # values for inspection even when they're not consumed + # downstream. "ARG005", # unused lambda arg — demo lambda signatures "ASYNC210", # blocking HTTP — tutorials may demo sync libs alongside async "ASYNC240", # os.path in async — examples favor stdlib readability From 24ab3e852ba1665e10dd98308b16b109efbc12c2 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 19:58:50 -0400 Subject: [PATCH 02/16] ci: minimal smoke workflow to bisect startup_failure Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 82 ++-------------------------------------- 1 file changed, 3 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5af528ed..fc147f13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,88 +6,12 @@ on: pull_request: workflow_dispatch: -# Minimal default permissions for the CI pipeline. Individual jobs can raise -# to the narrowest scope they actually need; reusable sub-workflows declare -# their own permissions blocks. permissions: contents: read -# If another push to the same PR or branch happens while this workflow is -# still running, cancel the earlier run in favor of the next run. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: - lint: - name: Lint - uses: ./.github/workflows/_lint.yml - permissions: - contents: read - - test: - name: Test - uses: ./.github/workflows/_test.yml - permissions: - contents: read - - precommit: - name: Pre-commit - uses: ./.github/workflows/_precommit.yml - permissions: - contents: read - - codespell: - name: Codespell - uses: ./.github/workflows/_codespell.yml - permissions: - contents: read - - # Verify commits are signed off (OCA compliance). Only runs on PRs — - # main pushes have already been signed off through the PR that - # produced them. - dco: - name: DCO Check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: read - steps: - # actions/checkout@v4 - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - fetch-depth: 0 - - - name: Check DCO - # tisonkun/actions-dco@v1.1 — pin to SHA so a deleted/rewritten - # tag can't silently change the action body. - uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - # Aggregate gate. If every required check above succeeded (or was - # skipped on a non-applicable event), this job succeeds; otherwise it - # fails. Branch-protection should require this single status check - # rather than each individual one — that lets us add / remove jobs - # without retouching protection rules. - ci-success: - name: CI Success - needs: [lint, test, precommit, codespell, dco] - if: always() + smoke: + name: Smoke runs-on: ubuntu-latest - permissions: {} steps: - - name: Verify required jobs succeeded - env: - NEEDS_JSON: ${{ toJSON(needs) }} - run: | - set -eu - echo "$NEEDS_JSON" - # Fail the gate if any upstream job failed or was cancelled. - # `skipped` is allowed (DCO is skipped on non-PR events). - if echo "$NEEDS_JSON" | grep -qE '"result":[[:space:]]*"(failure|cancelled)"'; then - echo "::error::At least one required job failed or was cancelled." - exit 1 - fi - echo "All required jobs passed." + - run: echo "hello world" From e950d0bbb4f0cbdf92a11e4aab06f750d6999a55 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:00:22 -0400 Subject: [PATCH 03/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=20add=20lint=20j?= =?UTF-8?q?ob=20(reusable=20workflow)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc147f13..e7f11552 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,6 @@ permissions: contents: read jobs: - smoke: - name: Smoke - runs-on: ubuntu-latest - steps: - - run: echo "hello world" + lint: + name: Lint + uses: ./.github/workflows/_lint.yml From 8e41f80ee0f0a7923636d3a44f458d08b23630cc Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:03:48 -0400 Subject: [PATCH 04/16] =?UTF-8?q?ci:=20bump=20HATCH=5FVERSION=201.14.0=20?= =?UTF-8?q?=E2=86=92=201.16.5=20+=20restore=20full=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hatch 1.14.0 broke at runtime in CI with: Environment `default` is incompatible: module 'virtualenv.discovery.builtin' has no attribute 'propose_interpreters' — a known incompatibility with newer ``virtualenv`` releases that hatch 1.14.0 hadn't been updated to handle. Fixed in hatch 1.16.x. Pin ``HATCH_VERSION: "1.16.5"`` (latest stable, 2026-02-27) across ``_lint.yml`` / ``_test.yml`` / ``_release.yml``. This was the actual root cause of every recent CI ``startup_failure`` — ``hatch run`` couldn't bootstrap the env, the lint job crashed at the first hatch invocation, and the workflow exited 1 before the ci-success aggregator could even run. Also restores ``ci.yml`` to its full multi-job orchestrator (lint + test + precommit + codespell + dco + ci-success aggregate gate) after the bisect commits reduced it to a single smoke job. Signed-off-by: Federico Kamelhar --- .github/workflows/_lint.yml | 2 +- .github/workflows/_release.yml | 2 +- .github/workflows/_test.yml | 2 +- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index f5f8f9af..eca8894f 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -7,7 +7,7 @@ permissions: contents: read env: - HATCH_VERSION: "1.14.0" + HATCH_VERSION: "1.16.5" RUFF_OUTPUT_FORMAT: github jobs: diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index 1f85e448..03b3cd73 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -13,7 +13,7 @@ permissions: env: PYTHON_VERSION: "3.11" - HATCH_VERSION: "1.14.0" + HATCH_VERSION: "1.16.5" jobs: test: diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index c1713d39..b83bb9b8 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -7,7 +7,7 @@ permissions: contents: read env: - HATCH_VERSION: "1.14.0" + HATCH_VERSION: "1.16.5" jobs: build: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7f11552..43ddf72d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,76 @@ on: pull_request: workflow_dispatch: +# Minimal default permissions for the CI pipeline. Individual jobs can raise +# to the narrowest scope they actually need; reusable sub-workflows declare +# their own permissions blocks. permissions: contents: read +# If another push to the same PR or branch happens while this workflow is +# still running, cancel the earlier run in favor of the next run. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint: name: Lint uses: ./.github/workflows/_lint.yml + + test: + name: Test + uses: ./.github/workflows/_test.yml + + precommit: + name: Pre-commit + uses: ./.github/workflows/_precommit.yml + + codespell: + name: Codespell + uses: ./.github/workflows/_codespell.yml + + # Verify commits are signed off (OCA compliance). Only runs on PRs — + # main pushes have already been signed off through the PR that + # produced them. + dco: + name: DCO Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + steps: + # actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + + - name: Check DCO + # tisonkun/actions-dco@v1.1 — pin to SHA so a deleted/rewritten + # tag can't silently change the action body. + uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # Aggregate gate. Branch-protection should require this single status + # check rather than each individual one — that lets us add / remove + # jobs without retouching protection rules. ``skipped`` is allowed + # (DCO is skipped on push events). + ci-success: + name: CI Success + needs: [lint, test, precommit, codespell, dco] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify required jobs succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + set -eu + echo "$NEEDS_JSON" + if echo "$NEEDS_JSON" | grep -qE '"result":[[:space:]]*"(failure|cancelled)"'; then + echo "::error::At least one required job failed or was cancelled." + exit 1 + fi + echo "All required jobs passed." From 1a78cb217684bac40532fa5735ab60ac8558a17f Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:08:35 -0400 Subject: [PATCH 05/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=20temporarily=20?= =?UTF-8?q?drop=20precommit=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43ddf72d..de0a3b12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,6 @@ jobs: name: Test uses: ./.github/workflows/_test.yml - precommit: - name: Pre-commit - uses: ./.github/workflows/_precommit.yml - codespell: name: Codespell uses: ./.github/workflows/_codespell.yml @@ -64,7 +60,7 @@ jobs: # (DCO is skipped on push events). ci-success: name: CI Success - needs: [lint, test, precommit, codespell, dco] + needs: [lint, test, codespell, dco] if: always() runs-on: ubuntu-latest steps: From edd3e145b27f0f4a209ffbb846964814169472f6 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:13:57 -0400 Subject: [PATCH 06/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=20drop=20ci-succ?= =?UTF-8?q?ess=20aggregator=20(lint+test+codespell+dco=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de0a3b12..85f593ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,25 +53,3 @@ jobs: uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 with: github-token: ${{ secrets.GITHUB_TOKEN }} - - # Aggregate gate. Branch-protection should require this single status - # check rather than each individual one — that lets us add / remove - # jobs without retouching protection rules. ``skipped`` is allowed - # (DCO is skipped on push events). - ci-success: - name: CI Success - needs: [lint, test, codespell, dco] - if: always() - runs-on: ubuntu-latest - steps: - - name: Verify required jobs succeeded - env: - NEEDS_JSON: ${{ toJSON(needs) }} - run: | - set -eu - echo "$NEEDS_JSON" - if echo "$NEEDS_JSON" | grep -qE '"result":[[:space:]]*"(failure|cancelled)"'; then - echo "::error::At least one required job failed or was cancelled." - exit 1 - fi - echo "All required jobs passed." From 4c7a3d8f0bd0dffe528b660794bc8a27beab4d71 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:17:59 -0400 Subject: [PATCH 07/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=20drop=20dco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85f593ab..0fc29223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,26 +30,3 @@ jobs: codespell: name: Codespell uses: ./.github/workflows/_codespell.yml - - # Verify commits are signed off (OCA compliance). Only runs on PRs — - # main pushes have already been signed off through the PR that - # produced them. - dco: - name: DCO Check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: read - steps: - # actions/checkout@v4 - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - fetch-depth: 0 - - - name: Check DCO - # tisonkun/actions-dco@v1.1 — pin to SHA so a deleted/rewritten - # tag can't silently change the action body. - uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} From d834f53fe1ba60b0a7b18db9e9f4961e091742eb Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:21:34 -0400 Subject: [PATCH 08/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=202=20inline=20+?= =?UTF-8?q?=201=20reusable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fc29223..411ee586 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,27 +6,22 @@ on: pull_request: workflow_dispatch: -# Minimal default permissions for the CI pipeline. Individual jobs can raise -# to the narrowest scope they actually need; reusable sub-workflows declare -# their own permissions blocks. permissions: contents: read -# If another push to the same PR or branch happens while this workflow is -# still running, cancel the earlier run in favor of the next run. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + smoke-1: + runs-on: ubuntu-latest + steps: + - run: echo one + smoke-2: + runs-on: ubuntu-latest + steps: + - run: echo two lint: name: Lint uses: ./.github/workflows/_lint.yml - - test: - name: Test - uses: ./.github/workflows/_test.yml - - codespell: - name: Codespell - uses: ./.github/workflows/_codespell.yml From f7d2e302beac6c38110d4f1e6626680f7a33cbf8 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:24:21 -0400 Subject: [PATCH 09/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=202=20reusables?= =?UTF-8?q?=20(lint+test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 411ee586..a7d9af1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,14 +14,9 @@ concurrency: cancel-in-progress: true jobs: - smoke-1: - runs-on: ubuntu-latest - steps: - - run: echo one - smoke-2: - runs-on: ubuntu-latest - steps: - - run: echo two lint: name: Lint uses: ./.github/workflows/_lint.yml + test: + name: Test + uses: ./.github/workflows/_test.yml From 8346080f85d2761616f1815dc7d63a881fe6d898 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:28:29 -0400 Subject: [PATCH 10/16] ci: install [dev,all] extras + restore full orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2-reusable bisect ran but hit ModuleNotFoundError for ``oci`` / ``openai`` / ``fastmcp`` etc. — ``hatch run test`` uses the default env which only carries ``[dev]`` deps. Switch ``_test.yml`` to a direct ``pip install -e ".[dev,all]"`` so every optional dep is available, and run ``pytest tests/unit/`` directly. Bypassing hatch here also avoids spinning up four per-matrix-slot virtualenvs. Restore ``ci.yml`` to the full orchestrator (lint + test + precommit + codespell + dco + ci-success) now that the 2-reusable bisect proved the multi-job structure works. Signed-off-by: Federico Kamelhar --- .github/workflows/_test.yml | 28 +++++++++++++------- .github/workflows/ci.yml | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index b83bb9b8..0146338a 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -6,14 +6,12 @@ on: permissions: contents: read -env: - HATCH_VERSION: "1.16.5" - jobs: build: - name: "make test #${{ matrix.python-version }}" + name: "test #${{ matrix.python-version }}" runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: - "3.11" @@ -30,14 +28,24 @@ jobs: with: python-version: ${{ matrix.python-version }} allow-prereleases: true + cache: pip + + # Install locus with every optional extra so tests that import + # ``oci``, ``openai``, ``fastmcp``, ``mcp``, ``redis``, etc. don't + # fail with ModuleNotFoundError. We bypass hatch here because + # ``hatch run test`` uses the default env which only carries + # ``[dev]`` deps; spinning up ``hatch -e test`` per matrix slot + # would re-resolve every dep into a per-Python virtualenv and + # dominate the CI wall-clock. + - name: Install locus + all extras + run: | + python -m pip install --upgrade pip + pip install -e ".[dev,all]" - - name: Install Hatch - run: pip install hatch==${{ env.HATCH_VERSION }} - - - name: Run core tests - run: hatch run test + - name: Run unit tests + run: pytest tests/unit/ - - name: Ensure the tests did not create any additional files + - name: Ensure tests did not create stray files run: | set -eu STATUS="$(git status)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7d9af1e..10cfb298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,58 @@ jobs: lint: name: Lint uses: ./.github/workflows/_lint.yml + test: name: Test uses: ./.github/workflows/_test.yml + + precommit: + name: Pre-commit + uses: ./.github/workflows/_precommit.yml + + codespell: + name: Codespell + uses: ./.github/workflows/_codespell.yml + + # Verify commits are signed off (OCA compliance). Only runs on PRs. + dco: + name: DCO Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + steps: + # actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + + - name: Check DCO + # tisonkun/actions-dco@v1.1 — pin to SHA so a deleted/rewritten + # tag can't silently change the action body. + uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # Aggregate gate. Branch-protection should require this single status + # check rather than each individual one — that lets us add / remove + # jobs without retouching protection rules. ``skipped`` is allowed + # (DCO is skipped on push events). + ci-success: + name: CI Success + needs: [lint, test, precommit, codespell, dco] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify required jobs succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + set -eu + echo "$NEEDS_JSON" + if echo "$NEEDS_JSON" | grep -qE '"result":[[:space:]]*"(failure|cancelled)"'; then + echo "::error::At least one required job failed or was cancelled." + exit 1 + fi + echo "All required jobs passed." From dabcbdad32c4fb10ec7ac06609c241c4aafac1e3 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:36:03 -0400 Subject: [PATCH 11/16] ci: drop separate codespell job (precommit runs it via the hook) Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10cfb298..db088a26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,6 @@ jobs: name: Pre-commit uses: ./.github/workflows/_precommit.yml - codespell: - name: Codespell - uses: ./.github/workflows/_codespell.yml - # Verify commits are signed off (OCA compliance). Only runs on PRs. dco: name: DCO Check @@ -57,7 +53,7 @@ jobs: # (DCO is skipped on push events). ci-success: name: CI Success - needs: [lint, test, precommit, codespell, dco] + needs: [lint, test, precommit, dco] if: always() runs-on: ubuntu-latest steps: From 8e428f041a6b9eac44a788d18943ad9942cf078f Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:38:23 -0400 Subject: [PATCH 12/16] ci: add codespell to .pre-commit-config.yaml Signed-off-by: Federico Kamelhar --- .pre-commit-config.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e45ffa09..0ea0c07a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -100,6 +100,25 @@ repos: # Skip if not using poetry stages: [manual] + # ========================================================================== + # Spell-checking + # ========================================================================== +- repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: + # Project-specific terms + identifiers + intentional typo-squat + # test fixtures that codespell otherwise mis-flags: + # te — loop variable for ``tool_execution`` + # whats — test data string ("whats" stripped from query) + # pres — variable shorthand for ``presence_penalty`` + # reqeusts — intentional typo-squat fixture for OSV malware test + # unparseable — accepted English variant of ``unparsable`` + # re-use — accepted hyphenation + - --ignore-words-list=oci,orcl,te,whats,re-use,pres,reqeusts,unparseable + - --skip=*.lock,*.json,*.yaml,*.yml,*.svg,*.gif,*.png,*.jpg,*.pdf + # ========================================================================== # Documentation # ========================================================================== From aa79e626f9a979c39e0a8b6638ccc485841f8835 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:48:50 -0400 Subject: [PATCH 13/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=203=20reusables?= =?UTF-8?q?=20only=20(no=20dco,=20no=20aggregator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 43 ---------------------------------------- 1 file changed, 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db088a26..769eac79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,46 +25,3 @@ jobs: precommit: name: Pre-commit uses: ./.github/workflows/_precommit.yml - - # Verify commits are signed off (OCA compliance). Only runs on PRs. - dco: - name: DCO Check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: read - steps: - # actions/checkout@v4 - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - with: - fetch-depth: 0 - - - name: Check DCO - # tisonkun/actions-dco@v1.1 — pin to SHA so a deleted/rewritten - # tag can't silently change the action body. - uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - # Aggregate gate. Branch-protection should require this single status - # check rather than each individual one — that lets us add / remove - # jobs without retouching protection rules. ``skipped`` is allowed - # (DCO is skipped on push events). - ci-success: - name: CI Success - needs: [lint, test, precommit, dco] - if: always() - runs-on: ubuntu-latest - steps: - - name: Verify required jobs succeeded - env: - NEEDS_JSON: ${{ toJSON(needs) }} - run: | - set -eu - echo "$NEEDS_JSON" - if echo "$NEEDS_JSON" | grep -qE '"result":[[:space:]]*"(failure|cancelled)"'; then - echo "::error::At least one required job failed or was cancelled." - exit 1 - fi - echo "All required jobs passed." From 1adcabf4413b12f2177303d584a5db6b0dceb269 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:52:26 -0400 Subject: [PATCH 14/16] =?UTF-8?q?ci:=20bisect=20=E2=80=94=203=20reusables?= =?UTF-8?q?=20+=20dco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 769eac79..b8b100f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,19 @@ jobs: precommit: name: Pre-commit uses: ./.github/workflows/_precommit.yml + + dco: + name: DCO Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + - name: Check DCO + uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} From 29bed7350ba19c7a4b580bec5ffd0b26d425ab38 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 20:55:52 -0400 Subject: [PATCH 15/16] ci: replace deprecated tisonkun/actions-dco with inline check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tisonkun/actions-dco` was last updated 2022-07-30 and uses Node 16, which GitHub Actions deprecated. Any workflow that includes it now fails with `startup_failure` before any job runs. **This was the root cause of every recent CI failure** — earlier bisects pointed to "the dco job" without me realizing the action itself was the deprecated piece. Replace with a 20-line inline bash check: walk every commit between ``pull_request.base.sha`` and ``pull_request.head.sha``, ensure each matches ``^Signed-off-by: .+ <.+@.+>$``, fail with a clear message listing offending commits + the fix command. Also restore the ``ci-success`` aggregator now that the dco job is fixed; that job depends on ``[lint, test, precommit, dco]`` and uses a simple grep over ``toJSON(needs)`` to require zero failures while allowing skipped (DCO is skipped on push events). Signed-off-by: Federico Kamelhar --- .github/workflows/ci.yml | 55 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8b100f1..00905727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,18 +26,63 @@ jobs: name: Pre-commit uses: ./.github/workflows/_precommit.yml + # Verify commits are signed off (OCA / DCO compliance). Inline bash + # check — replaces ``tisonkun/actions-dco`` which was deprecated by + # GitHub for using Node 16 (caused startup_failure on every CI run). dco: name: DCO Check runs-on: ubuntu-latest if: github.event_name == 'pull_request' permissions: contents: read - pull-requests: read steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 - - name: Check DCO - uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.head.sha }} + - name: Verify every commit is signed off + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -eu + echo "Checking commits between $BASE_SHA and $HEAD_SHA" + missing=$(git log --no-merges --pretty='%H %s' "$BASE_SHA..$HEAD_SHA" \ + | while read sha _subject; do + if ! git log -1 --pretty='%B' "$sha" \ + | grep -qE '^Signed-off-by: .+ <.+@.+>$'; then + printf '%s\n' "$sha" + fi + done) + if [ -n "$missing" ]; then + echo "::error::The following commits are missing a Signed-off-by line:" + echo "$missing" | while read sha; do + echo " - $(git log -1 --pretty='%h %s' "$sha")" + done + echo + echo "Add a sign-off with: git commit -s --amend --no-edit" + echo "and then force-push the branch." + exit 1 + fi + echo "All commits are signed off." + + # Aggregate gate. Branch-protection should require this single status + # check rather than each individual one — that lets us add / remove + # jobs without retouching protection rules. + ci-success: + name: CI Success + needs: [lint, test, precommit, dco] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify required jobs succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + set -eu + echo "$NEEDS_JSON" + if echo "$NEEDS_JSON" | grep -qE '"result":[[:space:]]*"(failure|cancelled)"'; then + echo "::error::At least one required job failed or was cancelled." + exit 1 + fi + echo "All required jobs passed." From 72f25d5c2c0dd18bb92c08cb05b356ed2ed05cb9 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 30 Apr 2026 21:18:22 -0400 Subject: [PATCH 16/16] ci: move dev-only deps into [dev] extra for CI install Signed-off-by: Federico Kamelhar --- pyproject.toml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c7c0ef08..9f17bd80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,15 @@ dev = [ "pytest-xdist>=3.5", "respx>=0.21", # Mock httpx "dirty-equals>=0.8", # Pydantic test helpers + # Dev-time deps that some unit tests import directly. Living here + # rather than in ``[dependency-groups].dev`` so a plain + # ``pip install -e ".[dev]"`` (used by CI and contributors) covers + # the entire test surface. + "aiosqlite>=0.22.1", # used by SQLite checkpointer tests + "fastmcp>=2.14.5", # MCP server tests in tests/unit/test_fastmcp.py + "pyyaml>=6.0.3", # Playbook YAML loader tests + "redis>=7.1.0", # Redis checkpointer tests + "sqlalchemy>=2.0.46", # PostgreSQL checkpointer schema tests ] # Docs @@ -592,11 +601,8 @@ exclude_lines = [ fail_under = 80 show_missing = true -[dependency-groups] -dev = [ - "aiosqlite>=0.22.1", - "fastmcp>=2.14.5", - "pyyaml>=6.0.3", - "redis>=7.1.0", - "sqlalchemy>=2.0.46", -] +# Note: ``[dependency-groups]`` (PEP 735) was previously used here but +# ``pip install -e ".[dev]"`` doesn't pick those up — the same deps are +# now in the ``[project.optional-dependencies].dev`` extra above. Kept +# this comment as a marker so a future ``hatch sync`` doesn't reintroduce +# the duplicate.