diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 0dbabc88..eca8894f 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -7,16 +7,18 @@ permissions: contents: read env: - HATCH_VERSION: "1.14.0" + HATCH_VERSION: "1.16.5" RUFF_OUTPUT_FORMAT: github 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/_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..0146338a 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -6,14 +6,12 @@ on: permissions: contents: read -env: - HATCH_VERSION: "1.14.0" - 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 456820b5..00905727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,9 @@ 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 @@ -22,56 +17,72 @@ jobs: lint: name: Lint uses: ./.github/workflows/_lint.yml - permissions: - contents: read test: name: Test uses: ./.github/workflows/_test.yml - permissions: - contents: read - codespell: - name: Codespell - uses: ./.github/workflows/_codespell.yml - permissions: - contents: read + precommit: + name: Pre-commit + uses: ./.github/workflows/_precommit.yml - # Verify commits are signed off (OCA compliance) + # 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: - # actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 + 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." - - 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. - 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. ci-success: name: CI Success - needs: [lint, test, codespell] + needs: [lint, test, precommit, 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" + 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/.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 # ========================================================================== 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..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 @@ -148,14 +157,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 +355,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 @@ -574,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.