diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26609aa..47d5791 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,8 +44,33 @@ jobs: 'import site; print(site.getsitepackages()[0])') python -m pip_audit --path "$product_site" + harness-assurance: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - profile: langgraph + package: "langchain-core>=1,<2" + - profile: crewai + package: "crewai>=1,<2" + - profile: openai-agents + package: "openai-agents>=0.19,<1" + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[dev]' '${{ matrix.package }}' + - name: Exercise current public harness objects + env: + MENDMARK_HARNESS: ${{ matrix.profile }} + run: python -m pytest tests/test_harness_live_compatibility.py + build: - needs: assurance + needs: [assurance, harness-assurance] runs-on: ubuntu-latest outputs: version: ${{ steps.package-version.outputs.version }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b396b8..049ee77 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,7 @@ name: tests on: push: + branches: [main] pull_request: workflow_dispatch: @@ -92,6 +93,31 @@ jobs: run: python -m pip install -e '.[deepeval,rubric,dev]' - run: python -m pytest tests/test_deepeval.py tests/test_rubric_example.py + harness-compatibility: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - profile: langgraph + package: "langchain-core>=1,<2" + - profile: crewai + package: "crewai>=1,<2" + - profile: openai-agents + package: "openai-agents>=0.19,<1" + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[dev]' '${{ matrix.package }}' + - name: Exercise current public harness objects + env: + MENDMARK_HARNESS: ${{ matrix.profile }} + run: python -m pytest tests/test_harness_live_compatibility.py + distribution-assurance: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index e26415c..60f2ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,24 @@ All notable changes to Mendmark are documented here. The project follows Semantic Versioning for its Python and JSON contracts. -## Unreleased +## 0.6.0 - 2026-08-10 + +### Added + +- Dependency-light, public-object adapters for LangChain/LangGraph messages, + CrewAI events, and OpenAI Agents SDK run items, including tool-schema and + side-effect metadata conversion. +- `mendmark equip` for bounded harness detection, non-destructive local + scaffolding, an offline evaluator, an inactive pinned CI template, and a + copyable coding-agent self-equip prompt. +- Live compatibility assurance against current releases of all three harness + paths, plus explicit human approval before observed traces can become + expected behavior. +- A fluent causal-case builder for reviewed multi-agent delegation, parallel + dependencies, tool authority, state changes, results, and aggregation without + hand-authoring schema 2.0 JSON. + +## 0.5.0 - 2026-08-06 ### Added diff --git a/README.md b/README.md index f394f05..f3efeb7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@

Quick start · + Harnesses · Golden set · Multi-agent · How it works · @@ -63,6 +64,31 @@ mendmark audit-json examples/multi_agent_suite.json \ --evaluator-command "python3 examples/multi_agent_evaluator.py" ``` +## Equip an agent harness + +Mendmark has dependency-light adapters for LangChain/LangGraph, CrewAI, and the +OpenAI Agents SDK. In an existing agent repository: + +```bash +python -m pip install 'mendmark-evals==0.6.0' +mendmark equip --framework auto +``` + +The command detects bounded dependency files and creates a reviewed capture +guide, offline evaluator, and inactive CI template under `.mendmark/`. It does +not edit application code, upload a trace, overwrite existing work, enable CI, +or accept a baseline. + +Want the repository's coding agent to perform the integration? + +```bash +mendmark equip --print-agent-prompt +``` + +See the [agent harness integration guide](docs/harness-integrations.md) for the +direct Python APIs, explicit trace-approval boundary, framework compatibility, +and multi-agent guidance. + ## See the blind spot in two minutes **[▶ Watch the narrated weak-eval demonstration (original v1 fault inventory)](docs/assets/mendmark-weak-eval-demo.mp4)** @@ -346,7 +372,7 @@ and the [ML evaluation card](https://github.com/danielgaskins/mendmark/blob/main ## Current boundary -Version 0.5 is a local, open-source engine. It does not yet provide a hosted +Version 0.6 is a local, open-source engine. It does not yet provide a hosted dashboard, team accounts, remote trace ingestion, or a secrets service. The planned control plane is described in [the product design](https://github.com/danielgaskins/mendmark/blob/main/docs/product.md). diff --git a/docs/assurance.md b/docs/assurance.md index 2073e1b..060a8d6 100644 --- a/docs/assurance.md +++ b/docs/assurance.md @@ -10,6 +10,11 @@ The automated assurance suite checks that: - `mendmark --version`, `--help`, packaged tasks, packaged schemas, and the complete single- and multi-agent JSON audit journeys work from that clean installation. +- Harness converters are tested with privacy-safe facsimiles and current public + objects from LangChain Core, CrewAI, and OpenAI Agents SDK. `mendmark equip` + is tested for idempotency, bounded detection, conflict refusal, symlink + containment, explicit expectation approval, and a complete generated + evaluator journey. - Reports, console output, JUnit, and SARIF do not expose canary values placed in prompts, answers, metadata, tags, tool arguments, tool outputs, or descriptions. - Repeated audits preserve mutation IDs, ordering, decisions, JUnit, SARIF, and diff --git a/docs/compatibility.md b/docs/compatibility.md index 7e9eaa1..f3d2658 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -18,9 +18,14 @@ Mendmark 0.x is evolving, but automation still needs predictable contracts. - Historical benchmarks select immutable `agent-eval-v1` or `multi-agent-v1` mutation profiles. Ordinary audits use `current`; adding a new operator never rewrites an older golden-set contract. +- Harness adapters are dependency-light and use documented public object + fields. Hosted CI exercises LangChain Core 1.5.3, CrewAI 1.15.14, and OpenAI + Agents SDK 0.19.4 as the 2026-08-10 compatibility snapshot. Newer supported + releases are tested through the latest-version harness matrix; Mendmark does + not install or pin a harness in customer environments. - A custom operator name is globally unique within an audit and is part of the customer's accepted baseline contract. -- `audit`, `audit-json`, `prepare`, `grade`, and `show` exit with 0 for success, +- `audit`, `audit-json`, `equip`, `prepare`, `grade`, and `show` exit with 0 for success, 1 for a failed product gate, and 2 for invalid input or infrastructure failure. The JSON Schemas under `mendmark/schemas` are the machine-readable contract. diff --git a/docs/harness-integrations.md b/docs/harness-integrations.md new file mode 100644 index 0000000..752331a --- /dev/null +++ b/docs/harness-integrations.md @@ -0,0 +1,224 @@ +# Agent harness integrations + +Mendmark converts public harness traces into its stable local JSON contract. It +does not import a harness at package import time, constrain the harness version, +or send trace content to a hosted service. + +## Supported first-class paths + +The initial targets were selected from active Python agent projects on +2026-08-10. GitHub stars are an imperfect adoption signal, so activity and a +stable tool/trace interface were considered as well. + +| Harness path | Adoption signal at selection | Mendmark input | +| --- | ---: | --- | +| LangChain / LangGraph | 143,910 / 39,385 GitHub stars | `AIMessage` and `ToolMessage` history | +| CrewAI | 56,908 GitHub stars | Public tool-usage and completion events | +| OpenAI Agents SDK | 28,542 GitHub stars | Public `RunResult.new_items` | + +The project sources are the [LangChain](https://github.com/langchain-ai/langchain), +[LangGraph](https://github.com/langchain-ai/langgraph), +[CrewAI](https://github.com/crewAIInc/crewAI), and +[OpenAI Agents SDK](https://github.com/openai/openai-agents-python) +repositories. Mendmark's compatibility job exercises current real objects from +each framework instead of relying only on local facsimiles. + +These paths follow the frameworks' documented structures: LangGraph exposes +tool lifecycle events and correlated message tool calls, CrewAI exposes an +event bus with tool-completion events, and the OpenAI Agents SDK exposes public +run items and trace processors. See the official [LangGraph event-streaming +guide](https://docs.langchain.com/oss/python/langgraph/event-streaming), +[CrewAI documentation](https://docs.crewai.com/), and [OpenAI Agents SDK tracing +guide](https://openai.github.io/openai-agents-python/tracing/). + +## One-command setup + +From the agent application repository: + +```bash +python -m pip install 'mendmark-evals==0.6.0' +mendmark equip --framework auto +``` + +Detection reads only bounded dependency files; it does not import or execute the +application. The command creates five reviewable files under `.mendmark/`: + +- `agent-setup.md`: harness-specific capture code and acceptance criteria. +- `evaluator.py`: a deterministic offline evaluator for reviewed snapshots. +- `mendmark-ci.yml`: an inactive, pinned CI template. +- `config.json`: detected integration metadata. +- `.gitignore`: excludes generated report artifacts. + +It never edits application code, activates CI, overwrites an existing differing +file, or accepts a baseline. Running it again is idempotent. Use `--dry-run` to +preview its targets. + +## Let a coding agent self-equip the repository + +Print a prompt that works with repository-capable coding agents: + +```bash +mendmark equip --print-agent-prompt +``` + +The prompt instructs the agent to run detection, read the generated setup file, +capture a real tool-using case, pass the audit, and meet every review criterion. +It explicitly forbids uploading trace content or silently treating observed +production behavior as correct. + +The short prompt can also be copied directly: + +> Run `mendmark equip --framework auto`, read `.mendmark/agent-setup.md` +> completely, integrate the detected harness, capture at least one reviewed +> tool-using case, run the local audit, and satisfy every acceptance criterion +> before enabling CI. Do not upload trace content or approve observed behavior +> without human review. + +## Direct Python API + +### LangChain and LangGraph + +```python +from mendmark.integrations import write_suite +from mendmark.integrations.langchain import case_from_messages, tool_specs + +case = case_from_messages( + result["messages"], + case_id="refund-reviewed", + input=test_input, + expected_output=expected_output, + approve_observed=True, +) +write_suite( + ".mendmark/suite.json", + [case], + tool_specs(tools, side_effecting=["refund_order"]), +) +``` + +Tool calls are joined to `ToolMessage` results by call ID. Both framework +objects and their documented dictionary forms are supported. + +### OpenAI Agents SDK + +```python +from mendmark.integrations import write_suite +from mendmark.integrations.openai_agents import case_from_result, tool_specs + +result = Runner.run_sync(agent, test_input) +case = case_from_result( + result, + case_id="refund-reviewed", + input=test_input, + expected_output=expected_output, + approve_observed=True, +) +write_suite( + ".mendmark/suite.json", + [case], + tool_specs(agent.tools, side_effecting=["refund_order"]), +) +``` + +The adapter pairs public `ToolCallItem` and `ToolCallOutputItem` objects using +their call ID. No OpenAI API call is made by the adapter. + +### CrewAI + +```python +from mendmark.integrations import write_suite +from mendmark.integrations.crewai import CrewAIRecorder, tool_specs + +recorder = CrewAIRecorder().attach() +result = crew.kickoff(inputs=test_inputs) +case = recorder.case( + case_id="refund-reviewed", + input=test_input, + expected_output=expected_output, + approve_observed=True, +) +write_suite( + ".mendmark/suite.json", + [case], + tool_specs(all_tools, side_effecting=["refund_order"]), +) +``` + +Attach one recorder in an isolated capture/test process and clear it between +cases. CrewAI's event bus is process-global, so concurrent crews should capture +in separate processes or pass their already-separated event sequences directly +to `case_from_events`. + +## Approval boundary + +All converters require `expected_tools` unless the caller explicitly supplies +`approve_observed=True`. That escape hatch exists for a one-off, human-reviewed +snapshot. It must not be applied automatically to arbitrary production traces. + +For maintained capture code, build and pass an explicit sequence of +`ToolCallRecord` expectations. Mark side-effecting tools explicitly: duplicate +payment, refund, email, write, and deployment calls are otherwise treated as +ordinary calls and receive weaker mutation severity. + +The adapters above produce the stable ordered-trace schema `1.0`. If agent +identity, delegation, parallel branches, shared state, or causal dependencies +matter, use `CausalCaseBuilder`, which emits Mendmark's native [multi-agent +schema 2.0](multi-agent.md). Do not flatten a coordination test and then claim +coverage of coordination failures. + +```python +from mendmark.integrations import CausalCaseBuilder, write_suite + +case = ( + CausalCaseBuilder( + case_id="parallel-review", + input=test_input, + root_agent_id="supervisor", + ) + .agent("supervisor") + .agent("billing", allowed_tools=["lookup_order", "refund_order"]) + .agent("risk", allowed_tools=["assess_risk"]) + .delegation("delegate-billing", "supervisor", "billing") + .delegation("delegate-risk", "supervisor", "risk") + .tool_call( + "lookup", + "billing", + "lookup_order", + input_parameters={"order_id": "104"}, + output={"status": "paid"}, + depends_on=["delegate-billing"], + ) + .result("billing-result", "billing", "supervisor", depends_on=["lookup"]) + .result("risk-result", "risk", "supervisor", depends_on=["delegate-risk"]) + .message( + "aggregate", + "supervisor", + depends_on=["billing-result", "risk-result"], + ) + .build( + actual_output=actual_output, + expected_output=expected_output, + approve_observed=True, + ) +) +write_suite(".mendmark/multi-agent-suite.json", [case], tool_contracts) +``` + +The builder validates agent identities, tool authority, unique event IDs, +dependency targets, and acyclicity. Dependencies remain explicit; it never +turns unreliable wall-clock ordering into a causal claim. + +## Run and activate CI + +```bash +mendmark audit-json .mendmark/suite.json \ + --evaluator-command "python .mendmark/evaluator.py" \ + --output .mendmark/report.json \ + --junit .mendmark/report.xml \ + --sarif .mendmark/report.sarif +``` + +Review survivors and create an accepted baseline only from a passing audit. +Then copy `.mendmark/mendmark-ci.yml` to +`.github/workflows/mendmark.yml`, open a pull request, and require its +`agent-eval-assurance` check after the first successful run. diff --git a/pyproject.toml b/pyproject.toml index e6457b2..34e4cc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mendmark-evals" -version = "0.5.0" +version = "0.6.0" description = "Mutation testing for agent evaluation suites" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/assure_distribution.py b/scripts/assure_distribution.py index b8eb4e0..c7ea910 100644 --- a/scripts/assure_distribution.py +++ b/scripts/assure_distribution.py @@ -94,6 +94,7 @@ def main() -> int: ("scripts", "assure_distribution.py"), (".github", "allowed_signers"), ("docs", "assurance.md"), + ("docs", "harness-integrations.md"), ("golden", "agent-eval-v1", "manifest.json"), ("golden", "agent-eval-v1", "suite.json"), ("golden", "agent-eval-v1", "results.json"), @@ -119,6 +120,53 @@ def main() -> int: help_text = run([str(mendmark), "--help"], cwd=workspace, env=clean_env) if "mutation-test" not in help_text.lower() or "audit-json" not in help_text: raise RuntimeError("installed CLI help is missing the primary product journey") + prompt = run( + [str(mendmark), "equip", "--print-agent-prompt"], + cwd=workspace, + env=clean_env, + ) + if "agent-setup.md" not in prompt or "human review" not in prompt: + raise RuntimeError("installed wheel did not expose safe agent self-equip guidance") + integration_api = run( + [ + str(python), + "-c", + ( + "from mendmark.integrations import CausalCaseBuilder, write_suite; " + "from mendmark.integrations.langchain import case_from_messages; " + "from mendmark.integrations.crewai import CrewAIRecorder; " + "from mendmark.integrations.openai_agents import case_from_result; " + "print('harness adapters ready')" + ), + ], + cwd=workspace, + env=clean_env, + ) + if integration_api.strip() != "harness adapters ready": + raise RuntimeError("installed wheel omitted the harness integration API") + equip_output = run( + [ + str(mendmark), + "equip", + "--framework", + "langgraph", + "--project-root", + str(workspace), + ], + cwd=workspace, + env=clean_env, + ) + if "Created: .mendmark/evaluator.py" not in equip_output: + raise RuntimeError("installed wheel did not scaffold a harness integration") + for generated in ( + "evaluator.py", + "agent-setup.md", + "mendmark-ci.yml", + "config.json", + ".gitignore", + ): + if not (workspace / ".mendmark" / generated).is_file(): + raise RuntimeError(f"installed wheel omitted equip asset: {generated}") tasks = run([str(mendmark), "tasks"], cwd=workspace, env=clean_env) if len([line for line in tasks.splitlines() if line.strip()]) != 5: raise RuntimeError("installed wheel did not expose all five ML integrity tasks") diff --git a/src/mendmark/__init__.py b/src/mendmark/__init__.py index d7b84fa..ff14598 100644 --- a/src/mendmark/__init__.py +++ b/src/mendmark/__init__.py @@ -2,7 +2,7 @@ from .agent_cases import AgentCase, AgentEvent, AgentSpec, ToolCallRecord, ToolSpec -__version__ = "0.5.0" +__version__ = "0.6.0" __all__ = [ "AgentCase", diff --git a/src/mendmark/cli.py b/src/mendmark/cli.py index 68f6c00..d86c685 100644 --- a/src/mendmark/cli.py +++ b/src/mendmark/cli.py @@ -82,6 +82,25 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("tasks", help="list and validate available tasks") + equip = subparsers.add_parser( + "equip", help="scaffold a reviewed agent-harness integration" + ) + equip.add_argument( + "--framework", + choices=("auto", "langgraph", "crewai", "openai-agents", "generic"), + default="auto", + help="detect from dependency files or select an integration (default: auto)", + ) + equip.add_argument("--project-root", default=".", help="project to equip") + equip.add_argument( + "--dry-run", action="store_true", help="show files without writing them" + ) + equip.add_argument( + "--print-agent-prompt", + action="store_true", + help="print a prompt for a repository coding agent without writing files", + ) + prepare = subparsers.add_parser("prepare", help="create a public agent workspace") prepare.add_argument("task_id") prepare.add_argument("--runs-root", default="runs") @@ -426,6 +445,27 @@ def main(argv: list[str] | None = None) -> int: ) return 0 + if args.command == "equip": + from .equip import agent_prompt, equip_project + + if args.print_agent_prompt: + print(agent_prompt(args.project_root)) + return 0 + frameworks, created, unchanged = equip_project( + args.project_root, + framework=args.framework, + dry_run=args.dry_run, + ) + action = "Would create" if args.dry_run else "Created" + print("Detected: " + ", ".join(frameworks)) + for path in created: + print(f"{action}: {path}") + for path in unchanged: + print(f"Unchanged: {path}") + if not args.dry_run: + print("Next: ask your coding agent to complete .mendmark/agent-setup.md") + return 0 + if args.command == "prepare": task = _task(tasks_root, args.task_id) run_dir = prepare_run( diff --git a/src/mendmark/equip.py b/src/mendmark/equip.py new file mode 100644 index 0000000..236150a --- /dev/null +++ b/src/mendmark/equip.py @@ -0,0 +1,345 @@ +"""Safe, review-first scaffolding for agent harness integrations.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Final + +from . import __version__ + + +class EquipError(ValueError): + """Raised when a project cannot be equipped without unsafe assumptions.""" + + +SUPPORTED_FRAMEWORKS: Final = ("langgraph", "crewai", "openai-agents", "generic") +_MARKER = "Generated by Mendmark equip; safe to regenerate." +_DEPENDENCY_FILES = ( + "pyproject.toml", + "requirements.txt", + "requirements-dev.txt", + "uv.lock", + "poetry.lock", + "pdm.lock", +) + + +def detect_frameworks(project_root: Path) -> tuple[str, ...]: + """Detect supported harness names from bounded dependency-file reads.""" + corpus: list[str] = [] + for name in _DEPENDENCY_FILES: + path = project_root / name + if path.is_file() and path.stat().st_size <= 2_000_000: + corpus.append(path.read_text(encoding="utf-8", errors="replace").lower()) + text = "\n".join(corpus) + found: list[str] = [] + if "langgraph" in text or "langchain" in text: + found.append("langgraph") + if "crewai" in text: + found.append("crewai") + if "openai-agents" in text or "openai_agents" in text: + found.append("openai-agents") + return tuple(found or ("generic",)) + + +def agent_prompt(project_root: str = ".") -> str: + """Return the short prompt a user can give any repository coding agent.""" + return ( + f"In {project_root}, run `mendmark equip --framework auto`, then read " + "`.mendmark/agent-setup.md` completely. Integrate the detected harness, " + "capture at least one reviewed tool-using case, run the local audit, and " + "satisfy every acceptance criterion before enabling the generated CI " + "workflow. Do not upload trace content or approve observed behavior " + "without human review." + ) + + +def _evaluator() -> str: + return f'''#!/usr/bin/env python3 +"""{_MARKER} + +Offline exact evaluator for reviewed Mendmark trace snapshots. +""" +from __future__ import annotations + +import json +import sys + + +def main() -> int: + request = json.load(sys.stdin) + evaluations = [] + for requested in request["evaluations"]: + case = requested["case"] + tools_match = case.get("tools_called", []) == case.get("expected_tools", []) + output_matches = ( + case.get("expected_output") is None + or case.get("actual_output") == case.get("expected_output") + ) + events_match = case.get("events", []) == case.get("expected_events", []) + is_multi = "events" in case or "expected_events" in case + results = [ + {{ + "name": "Reviewed tool trace", + "score": float(tools_match), + "passed": tools_match, + "reason": "Actual tool calls match the reviewed expected calls.", + }}, + {{ + "name": "Reviewed outcome", + "score": float(output_matches), + "passed": output_matches, + "reason": "The outcome matches when an expected outcome is configured.", + }}, + ] + if is_multi: + results.append( + {{ + "name": "Reviewed coordination graph", + "score": float(events_match), + "passed": events_match, + "reason": "Actual causal events match the reviewed graph.", + }} + ) + evaluations.append( + {{"evaluation_id": requested["evaluation_id"], "results": results}} + ) + json.dump( + {{"schema_version": request["schema_version"], "evaluations": evaluations}}, + sys.stdout, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +''' + + +def _adapter_example(framework: str) -> str: + if framework == "langgraph": + return '''```python +from mendmark.integrations.langchain import case_from_messages, tool_specs +from mendmark.integrations import write_suite + +# `result` is the graph/agent state and `tools` is the configured tool list. +case = case_from_messages( + result["messages"], + case_id="reviewed-order-flow", + input=test_input, + expected_output=reviewed_expected_output, + approve_observed=True, # Only in the one-off capture after human review. +) +write_suite( + ".mendmark/suite.json", + [case], + tool_specs(tools, side_effecting=["refund_order"]), +) +```''' + if framework == "openai-agents": + return '''```python +from mendmark.integrations.openai_agents import case_from_result, tool_specs +from mendmark.integrations import write_suite + +# `result` is returned by Runner.run/run_sync and `agent` is the configured Agent. +case = case_from_result( + result, + case_id="reviewed-order-flow", + input=test_input, + expected_output=reviewed_expected_output, + approve_observed=True, # Only in the one-off capture after human review. +) +write_suite( + ".mendmark/suite.json", + [case], + tool_specs(agent.tools, side_effecting=["refund_order"]), +) +```''' + if framework == "crewai": + return '''```python +from mendmark.integrations.crewai import CrewAIRecorder, tool_specs +from mendmark.integrations import write_suite + +recorder = CrewAIRecorder().attach() # Attach before crew.kickoff(). +result = crew.kickoff(inputs=test_inputs) +case = recorder.case( + case_id="reviewed-order-flow", + input=test_input, + expected_output=reviewed_expected_output, + approve_observed=True, # Only in the one-off capture after human review. +) +write_suite( + ".mendmark/suite.json", + [case], + tool_specs(all_tools, side_effecting=["refund_order"]), +) +```''' + return '''Use the framework-neutral JSON contract documented at +`https://github.com/danielgaskins/mendmark/blob/main/docs/json-adapter.md`. +Create a reviewed suite at `.mendmark/suite.json`; do not send trace content to +Mendmark or any hosted service.''' + + +def _instructions(frameworks: tuple[str, ...]) -> str: + detected = ", ".join(frameworks) + examples = "\n\n".join( + f"### {framework}\n\n{_adapter_example(framework)}" for framework in frameworks + ) + return f''' +# Equip this repository with Mendmark + +Detected integration: **{detected}**. + +Mendmark mutation-tests the evaluator, not the agent. Keep all trace content +local. The generated exact evaluator is a safe starting point for reviewed +snapshots; replace or supplement it with the project's real evaluator when +available. + +## Capture a reviewed case + +{examples} + +`approve_observed=True` is deliberately explicit. Inspect tool names, +arguments, results, ordering, side effects, and the expected outcome before +running the capture. Prefer passing an explicit `expected_tools` sequence in a +maintained capture test. Never turn arbitrary production traces into expected +behavior automatically. + +## Run locally + +```bash +mendmark audit-json .mendmark/suite.json \\ + --evaluator-command "python .mendmark/evaluator.py" \\ + --output .mendmark/report.json \\ + --junit .mendmark/report.xml \\ + --sarif .mendmark/report.sarif +``` + +Review every survivor. When the suite is accepted, create its baseline once +with `--write-baseline`, review `.mendmark/baseline.json`, and commit it. Copy +`.mendmark/mendmark-ci.yml` to `.github/workflows/mendmark.yml` only after the +local command passes. + +## Acceptance criteria + +- [ ] At least one consequential, tool-using case is captured. +- [ ] A human reviewed expected calls, arguments, outputs, order, and outcome. +- [ ] Every side-effecting tool is declared with `side_effecting=True`. +- [ ] The original case passes before mutation. +- [ ] Critical survivors were fixed or explicitly documented and accepted. +- [ ] No prompt, arguments, result content, credentials, or PII appears in the report. +- [ ] The baseline was generated only from a passing audit and reviewed in diff. +- [ ] The generated CI workflow passes on a pull request before becoming required. + +For native multi-agent coordination mutation testing, model the causal graph +with `mendmark.integrations.CausalCaseBuilder` instead of flattening it into an +ordered tool trace. See `docs/multi-agent.md` in Mendmark's repository. +''' + + +def _workflow() -> str: + return f'''# {_MARKER} +name: mendmark + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + agent-eval-assurance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install . 'mendmark-evals=={__version__}' + - name: Mutation-test agent evals + run: >- + mendmark audit-json .mendmark/suite.json + --evaluator-command "python .mendmark/evaluator.py" + --baseline .mendmark/baseline.json + --output .mendmark/report.json + --junit .mendmark/report.xml + --sarif .mendmark/report.sarif + --maximum-mutants 10000 + - uses: github/codeql-action/upload-sarif@b374143c1149a911a7c303ebe6abb5c19149a01c # v3 + if: always() && hashFiles('.mendmark/report.sarif') != '' + continue-on-error: true + with: + sarif_file: .mendmark/report.sarif +''' + + +def _files(frameworks: tuple[str, ...]) -> dict[str, str]: + return { + ".mendmark/evaluator.py": _evaluator(), + ".mendmark/agent-setup.md": _instructions(frameworks), + ".mendmark/mendmark-ci.yml": _workflow(), + ".mendmark/config.json": json.dumps( + {"schema_version": "1.0", "frameworks": list(frameworks)}, + indent=2, + sort_keys=True, + ) + + "\n", + ".mendmark/.gitignore": ( + f"# {_MARKER}\nreport.json\nreport.xml\nreport.sarif\n" + ), + } + + +def equip_project( + project_root: str | Path, + *, + framework: str = "auto", + dry_run: bool = False, +) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]: + """Create non-destructive integration assets. + + Returns ``(frameworks, created, unchanged)``. Existing differing files are + never overwritten; the user must review and resolve them manually. + """ + root = Path(project_root).expanduser().resolve() + if not root.is_dir(): + raise EquipError(f"project root is not a directory: {root}") + if framework != "auto" and framework not in SUPPORTED_FRAMEWORKS: + raise EquipError(f"unsupported framework: {framework}") + frameworks = detect_frameworks(root) if framework == "auto" else (framework,) + generated = _files(frameworks) + created: list[str] = [] + unchanged: list[str] = [] + conflicts: list[str] = [] + for relative, content in generated.items(): + destination = root / relative + resolved_destination = destination.resolve() + if root not in resolved_destination.parents: + conflicts.append(relative) + continue + if destination.exists(): + if destination.is_symlink() or not destination.is_file(): + conflicts.append(relative) + elif destination.read_text(encoding="utf-8") == content: + unchanged.append(relative) + else: + conflicts.append(relative) + else: + created.append(relative) + if conflicts: + raise EquipError( + "refusing to overwrite existing integration file(s): " + + ", ".join(conflicts) + ) + if not dry_run: + for relative in created: + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if root not in destination.resolve().parents: + raise EquipError(f"integration path escapes project root: {relative}") + destination.write_text(generated[relative], encoding="utf-8") + return frameworks, tuple(created), tuple(unchanged) diff --git a/src/mendmark/integrations/__init__.py b/src/mendmark/integrations/__init__.py new file mode 100644 index 0000000..2989cd1 --- /dev/null +++ b/src/mendmark/integrations/__init__.py @@ -0,0 +1,15 @@ +"""Dependency-light adapters for popular Python agent harnesses. + +The adapters use public, duck-typed harness objects so importing Mendmark never +imports or constrains the customer's agent framework. +""" + +from .common import HarnessIntegrationError, suite_to_json, write_suite +from .multi_agent import CausalCaseBuilder + +__all__ = [ + "HarnessIntegrationError", + "CausalCaseBuilder", + "suite_to_json", + "write_suite", +] diff --git a/src/mendmark/integrations/common.py b/src/mendmark/integrations/common.py new file mode 100644 index 0000000..a6f42b1 --- /dev/null +++ b/src/mendmark/integrations/common.py @@ -0,0 +1,206 @@ +"""Shared helpers for converting harness traces to Mendmark suites.""" + +from __future__ import annotations + +import json +import os +import tempfile +from collections.abc import Mapping, Sequence +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any + +from ..agent_cases import AgentCase, ToolCallRecord, ToolSpec +from ..json_adapter import case_to_json, load_json_suite + + +class HarnessIntegrationError(ValueError): + """Raised when a harness object cannot be converted without guessing.""" + + +def member(value: Any, name: str, default: Any = None) -> Any: + """Read a public field from either an object or mapping.""" + if isinstance(value, Mapping): + return value.get(name, default) + return getattr(value, name, default) + + +def json_value(value: Any, *, _depth: int = 0) -> Any: + """Convert common harness/Pydantic values to deterministic JSON values.""" + if _depth > 32: + raise HarnessIntegrationError("harness value nesting exceeds 32 levels") + if value is None or isinstance(value, (str, int, float, bool)): + return value + if hasattr(value, "model_dump"): + value = value.model_dump(mode="json") + elif is_dataclass(value) and not isinstance(value, type): + value = asdict(value) + if isinstance(value, Mapping): + return { + str(key): json_value(item, _depth=_depth + 1) + for key, item in value.items() + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [json_value(item, _depth=_depth + 1) for item in value] + return str(value) + + +def json_arguments(value: Any, *, location: str) -> dict[str, Any]: + """Normalize the JSON-object arguments used by agent tool calls.""" + if value is None or value == "": + return {} + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError as error: + raise HarnessIntegrationError( + f"{location} contains invalid JSON arguments" + ) from error + normalized = json_value(value) + if not isinstance(normalized, dict): + raise HarnessIntegrationError(f"{location} arguments must be a JSON object") + return normalized + + +def text_output(value: Any) -> str: + """Normalize a harness final output while preserving structured content.""" + if value is None: + return "" + if isinstance(value, str): + return value + normalized = json_value(value) + if isinstance(normalized, str): + return normalized + return json.dumps(normalized, sort_keys=True, separators=(",", ":")) + + +def schema_from_tool(tool: Any, *, framework: str) -> dict[str, Any]: + """Extract a public JSON input schema from common harness tool objects.""" + for attribute in ("params_json_schema", "args_schema", "tool_call_schema"): + candidate = member(tool, attribute) + if candidate is None: + continue + if callable(candidate) and not isinstance(candidate, type): + candidate = candidate() + if isinstance(candidate, type): + if hasattr(candidate, "model_json_schema"): + candidate = candidate.model_json_schema() + elif hasattr(candidate, "schema"): + candidate = candidate.schema() + if hasattr(candidate, "model_json_schema"): + candidate = candidate.model_json_schema() + if isinstance(candidate, Mapping): + return json_value(candidate) + raise HarnessIntegrationError( + f"{framework} tool {member(tool, 'name', '')!r} does not expose " + "a JSON input schema" + ) + + +def tool_spec_from_object( + tool: Any, + *, + framework: str, + side_effecting: bool = False, +) -> ToolSpec: + name = member(tool, "name") or member(tool, "tool_name") + if not isinstance(name, str) or not name.strip(): + raise HarnessIntegrationError(f"{framework} tool has no stable name") + description = member(tool, "description") + if description is not None: + description = str(description).strip() or None + return ToolSpec( + name=name, + input_schema=schema_from_tool(tool, framework=framework), + description=description, + side_effecting=side_effecting, + ) + + +def approved_expected_calls( + observed: Sequence[ToolCallRecord], + expected: Sequence[ToolCallRecord] | None, + *, + approve_observed: bool, +) -> tuple[ToolCallRecord, ...]: + if expected is not None: + return tuple(expected) + if approve_observed: + return tuple(observed) + raise HarnessIntegrationError( + "expected tool calls are required; after reviewing the trace, pass " + "expected_tools=... or explicitly set approve_observed=True" + ) + + +def _tool_to_json(tool: ToolSpec) -> dict[str, Any]: + value: dict[str, Any] = { + "name": tool.name, + "input_schema": tool.input_schema, + "side_effecting": tool.side_effecting, + } + if tool.description is not None: + value["description"] = tool.description + return value + + +def suite_to_json( + cases: Sequence[AgentCase], + tools: Sequence[ToolSpec], + *, + policy: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Create a JSON suite from converted cases without writing to disk.""" + if not cases: + raise HarnessIntegrationError("a Mendmark suite needs at least one case") + multi_agent = any(case.is_multi_agent for case in cases) + if multi_agent and not all(case.is_multi_agent for case in cases): + raise HarnessIntegrationError( + "single-agent and multi-agent cases must be written to separate suites" + ) + return { + "schema_version": "2.0" if multi_agent else "1.0", + "policy": dict( + policy + or { + "minimum_kill_rate": 0.9, + "fail_on_critical_survivor": True, + "fail_on_untested_tools": True, + "fail_on_tool_contract_issues": True, + "fail_on_regression": True, + } + ), + "tools": [_tool_to_json(tool) for tool in tools], + "cases": [case_to_json(case) for case in cases], + } + + +def write_suite( + path: str | Path, + cases: Sequence[AgentCase], + tools: Sequence[ToolSpec], + *, + policy: Mapping[str, Any] | None = None, + overwrite: bool = False, +) -> Path: + """Validate and atomically write a harness-derived Mendmark suite.""" + destination = Path(path).expanduser().resolve() + if destination.exists() and not overwrite: + raise HarnessIntegrationError( + f"refusing to overwrite existing suite: {destination}" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + payload = suite_to_json(cases, tools, policy=policy) + descriptor, raw_temp = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent + ) + temporary = Path(raw_temp) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + load_json_suite(temporary) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + return destination diff --git a/src/mendmark/integrations/crewai.py b/src/mendmark/integrations/crewai.py new file mode 100644 index 0000000..ce85f81 --- /dev/null +++ b/src/mendmark/integrations/crewai.py @@ -0,0 +1,135 @@ +"""CrewAI event adapters and a one-line event collector.""" + +from __future__ import annotations + +import threading +from collections.abc import Sequence +from typing import Any + +from ..agent_cases import AgentCase, ToolCallRecord, ToolSpec +from .common import ( + HarnessIntegrationError, + approved_expected_calls, + json_arguments, + json_value, + member, + text_output, + tool_spec_from_object, +) + + +def case_from_events( + events: Sequence[Any], + *, + case_id: str, + input: str, + expected_output: str | None, + expected_tools: Sequence[ToolCallRecord] | None = None, + approve_observed: bool = False, + tags: Sequence[str] = (), +) -> AgentCase: + """Convert CrewAI public tool-usage and agent-completion events.""" + calls: list[ToolCallRecord] = [] + final_output = "" + for event in events: + event_type = member(event, "type") + if event_type == "tool_usage_finished": + name = member(event, "tool_name") + if not isinstance(name, str) or not name: + raise HarnessIntegrationError("CrewAI tool event has no stable name") + calls.append( + ToolCallRecord( + name=name, + input_parameters=json_arguments( + member(event, "tool_args", {}), + location=f"CrewAI tool call {name!r}", + ), + output=json_value(member(event, "output")), + ) + ) + elif event_type == "agent_execution_completed": + final_output = text_output(member(event, "output")) + elif event_type == "crew_kickoff_completed": + output = member(event, "output") + final_output = text_output(member(output, "raw", output)) + if not calls: + raise HarnessIntegrationError("CrewAI event collection contains no tool calls") + expected = approved_expected_calls( + calls, expected_tools, approve_observed=approve_observed + ) + return AgentCase( + case_id=case_id, + input=input, + actual_output=final_output, + expected_output=expected_output, + tools_called=tuple(calls), + expected_tools=expected, + tags=tuple(tags), + metadata={"harness": "crewai"}, + ) + + +class CrewAIRecorder: + """Thread-safe collector registered against CrewAI's public event bus.""" + + def __init__(self) -> None: + self._events: list[Any] = [] + self._lock = threading.Lock() + self._attached = False + + @property + def events(self) -> tuple[Any, ...]: + with self._lock: + return tuple(self._events) + + def clear(self) -> None: + with self._lock: + self._events.clear() + + def record(self, event: Any) -> None: + with self._lock: + self._events.append(event) + + def attach(self) -> "CrewAIRecorder": + """Subscribe once to stable CrewAI completion event classes.""" + if self._attached: + return self + try: + from crewai.events import crewai_event_bus + from crewai.events.types.agent_events import AgentExecutionCompletedEvent + from crewai.events.types.crew_events import CrewKickoffCompletedEvent + from crewai.events.types.tool_usage_events import ToolUsageFinishedEvent + except ImportError as error: + raise HarnessIntegrationError( + "CrewAI is not installed; install it in the agent application's environment" + ) from error + + for event_class in ( + ToolUsageFinishedEvent, + AgentExecutionCompletedEvent, + CrewKickoffCompletedEvent, + ): + def handler(_source: Any, event: Any, *, recorder: CrewAIRecorder = self) -> None: + recorder.record(event) + + crewai_event_bus.on(event_class)(handler) + self._attached = True + return self + + def case(self, **kwargs: Any) -> AgentCase: + return case_from_events(self.events, **kwargs) + + +def tool_specs( + tools: Sequence[Any], *, side_effecting: Sequence[str] = () +) -> tuple[ToolSpec, ...]: + """Convert CrewAI BaseTool-compatible objects.""" + side_effect_names = set(side_effecting) + return tuple( + tool_spec_from_object( + tool, + framework="CrewAI", + side_effecting=str(member(tool, "name")) in side_effect_names, + ) + for tool in tools + ) diff --git a/src/mendmark/integrations/langchain.py b/src/mendmark/integrations/langchain.py new file mode 100644 index 0000000..270ec71 --- /dev/null +++ b/src/mendmark/integrations/langchain.py @@ -0,0 +1,113 @@ +"""LangChain and LangGraph message adapters.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from ..agent_cases import AgentCase, ToolCallRecord, ToolSpec +from .common import ( + HarnessIntegrationError, + approved_expected_calls, + json_arguments, + json_value, + member, + text_output, + tool_spec_from_object, +) + + +def _message_kind(message: Any) -> str: + return str(member(message, "type", member(message, "role", ""))).lower() + + +def _call_id(value: Any) -> str | None: + identifier = member(value, "id") or member(value, "tool_call_id") + return str(identifier) if identifier is not None else None + + +def case_from_messages( + messages: Sequence[Any], + *, + case_id: str, + input: str, + expected_output: str | None, + expected_tools: Sequence[ToolCallRecord] | None = None, + approve_observed: bool = False, + tags: Sequence[str] = (), +) -> AgentCase: + """Convert LangChain/LangGraph AIMessage and ToolMessage history. + + Tool calls are correlated with ToolMessage results by their public call IDs. + Dict-form messages using ``role`` are supported alongside message objects. + """ + outputs: dict[str, Any] = {} + for message in messages: + kind = _message_kind(message) + if kind in {"tool", "toolmessage"}: + identifier = member(message, "tool_call_id") + if identifier is not None: + output = member(message, "artifact", None) + if output is None: + output = member(message, "content") + outputs[str(identifier)] = json_value(output) + + calls: list[ToolCallRecord] = [] + final_output = "" + for message in messages: + kind = _message_kind(message) + raw_calls = member(message, "tool_calls", ()) or () + if kind in {"ai", "assistant", "aimessage"}: + content = member(message, "content", "") + if content and not raw_calls: + final_output = text_output(content) + for index, raw_call in enumerate(raw_calls): + function = member(raw_call, "function", {}) or {} + name = member(raw_call, "name") or member(function, "name") + if not isinstance(name, str) or not name: + raise HarnessIntegrationError( + f"LangChain tool call {index} has no stable name" + ) + arguments = member(raw_call, "args", None) + if arguments is None: + arguments = member(function, "arguments", {}) + identifier = _call_id(raw_call) + calls.append( + ToolCallRecord( + name=name, + input_parameters=json_arguments( + arguments, location=f"LangChain tool call {name!r}" + ), + output=outputs.get(identifier) if identifier else None, + ) + ) + if not calls: + raise HarnessIntegrationError("LangChain trace contains no tool calls") + expected = approved_expected_calls( + calls, expected_tools, approve_observed=approve_observed + ) + return AgentCase( + case_id=case_id, + input=input, + actual_output=final_output, + expected_output=expected_output, + tools_called=tuple(calls), + expected_tools=expected, + tags=tuple(tags), + metadata={"harness": "langchain-langgraph"}, + ) + + +def tool_specs( + tools: Sequence[Any], *, side_effecting: Sequence[str] = () +) -> tuple[ToolSpec, ...]: + """Convert LangChain BaseTool-compatible objects to Mendmark contracts.""" + side_effect_names = set(side_effecting) + return tuple( + tool_spec_from_object( + tool, + framework="LangChain", + side_effecting=str(member(tool, "name")) in side_effect_names, + ) + for tool in tools + ) diff --git a/src/mendmark/integrations/multi_agent.py b/src/mendmark/integrations/multi_agent.py new file mode 100644 index 0000000..66401d1 --- /dev/null +++ b/src/mendmark/integrations/multi_agent.py @@ -0,0 +1,219 @@ +"""A fluent, framework-neutral builder for reviewed causal agent traces.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from ..agent_cases import ( + AgentCase, + AgentEvent, + AgentSpec, + ToolCallRecord, + case_graph_issues, +) +from .common import HarnessIntegrationError, json_arguments, json_value + + +class CausalCaseBuilder: + """Build a validated schema 2.0 case without hand-writing JSON. + + Dependencies are always explicit. The builder never infers causality from + wall-clock ordering, which would be incorrect for parallel harness runs. + """ + + def __init__(self, *, case_id: str, input: str, root_agent_id: str) -> None: + self.case_id = case_id + self.input = input + self.root_agent_id = root_agent_id + self._agents: list[AgentSpec] = [] + self._events: list[AgentEvent] = [] + + def agent( + self, + agent_id: str, + *, + role: str | None = None, + description: str | None = None, + allowed_tools: Sequence[str] = (), + ) -> "CausalCaseBuilder": + self._agents.append( + AgentSpec( + agent_id=agent_id, + role=role, + description=description, + allowed_tools=tuple(allowed_tools), + ) + ) + return self + + def event( + self, + event_id: str, + kind: str, + actor_id: str, + *, + target_agent_id: str | None = None, + depends_on: Sequence[str] = (), + tool_call: ToolCallRecord | None = None, + payload: Any = None, + ) -> "CausalCaseBuilder": + self._events.append( + AgentEvent( + event_id=event_id, + kind=kind, + actor_id=actor_id, + target_agent_id=target_agent_id, + depends_on=tuple(depends_on), + tool_call=tool_call, + payload=json_value(payload), + ) + ) + return self + + def delegation( + self, + event_id: str, + actor_id: str, + target_agent_id: str, + *, + depends_on: Sequence[str] = (), + payload: Any = None, + ) -> "CausalCaseBuilder": + return self.event( + event_id, + "delegation", + actor_id, + target_agent_id=target_agent_id, + depends_on=depends_on, + payload=payload, + ) + + def tool_call( + self, + event_id: str, + actor_id: str, + name: str, + *, + input_parameters: Mapping[str, Any] | str | None = None, + output: Any = None, + depends_on: Sequence[str] = (), + description: str | None = None, + ) -> "CausalCaseBuilder": + return self.event( + event_id, + "tool_call", + actor_id, + depends_on=depends_on, + tool_call=ToolCallRecord( + name=name, + input_parameters=json_arguments( + input_parameters, + location=f"multi-agent tool call {name!r}", + ), + output=json_value(output), + description=description, + ), + ) + + def result( + self, + event_id: str, + actor_id: str, + target_agent_id: str, + *, + depends_on: Sequence[str] = (), + payload: Any = None, + ) -> "CausalCaseBuilder": + return self.event( + event_id, + "agent_result", + actor_id, + target_agent_id=target_agent_id, + depends_on=depends_on, + payload=payload, + ) + + def message( + self, + event_id: str, + actor_id: str, + *, + target_agent_id: str | None = None, + depends_on: Sequence[str] = (), + payload: Any = None, + ) -> "CausalCaseBuilder": + return self.event( + event_id, + "message", + actor_id, + target_agent_id=target_agent_id, + depends_on=depends_on, + payload=payload, + ) + + def state_update( + self, + event_id: str, + actor_id: str, + *, + depends_on: Sequence[str] = (), + payload: Any = None, + ) -> "CausalCaseBuilder": + return self.event( + event_id, + "state_update", + actor_id, + depends_on=depends_on, + payload=payload, + ) + + def build( + self, + *, + actual_output: str, + expected_output: str | None, + expected_events: Sequence[AgentEvent] | None = None, + approve_observed: bool = False, + tags: Sequence[str] = (), + metadata: Mapping[str, Any] | None = None, + ) -> AgentCase: + if expected_events is None and not approve_observed: + raise HarnessIntegrationError( + "expected causal events are required; after reviewing the graph, " + "pass expected_events=... or explicitly set approve_observed=True" + ) + expected = tuple(self._events if expected_events is None else expected_events) + case = AgentCase( + case_id=self.case_id, + input=self.input, + actual_output=actual_output, + expected_output=expected_output, + agents=tuple(self._agents), + events=tuple(self._events), + expected_events=expected, + root_agent_id=self.root_agent_id, + tags=tuple(tags), + metadata={"harness": "causal-builder", **dict(metadata or {})}, + ) + issues = case_graph_issues(case) + if issues: + issue = issues[0] + event = f" at event {issue['event_id']!r}" if "event_id" in issue else "" + raise HarnessIntegrationError( + f"invalid causal graph{event}: {issue['issue']}" + ) + allowed = {agent.agent_id: set(agent.allowed_tools) for agent in case.agents} + for trace_name, events in (("actual", case.events), ("expected", case.expected_events)): + for event in events: + if ( + event.kind == "tool_call" + and event.tool_call is not None + and event.tool_call.name not in allowed[event.actor_id] + ): + raise HarnessIntegrationError( + f"{trace_name} event {event.event_id!r} calls tool " + f"{event.tool_call.name!r} outside agent " + f"{event.actor_id!r}'s allow-list" + ) + return case diff --git a/src/mendmark/integrations/openai_agents.py b/src/mendmark/integrations/openai_agents.py new file mode 100644 index 0000000..a6060a7 --- /dev/null +++ b/src/mendmark/integrations/openai_agents.py @@ -0,0 +1,106 @@ +"""OpenAI Agents SDK result adapters.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from ..agent_cases import AgentCase, ToolCallRecord, ToolSpec +from .common import ( + HarnessIntegrationError, + approved_expected_calls, + json_arguments, + json_value, + member, + text_output, + tool_spec_from_object, +) + + +def _raw(item: Any) -> Any: + return member(item, "raw_item", {}) or {} + + +def _call_id(item: Any) -> str | None: + identifier = member(item, "call_id") + if identifier is None: + raw = _raw(item) + identifier = member(raw, "call_id") or member(raw, "id") + return str(identifier) if identifier is not None else None + + +def case_from_result( + result: Any, + *, + case_id: str, + input: str, + expected_output: str | None, + expected_tools: Sequence[ToolCallRecord] | None = None, + approve_observed: bool = False, + tags: Sequence[str] = (), +) -> AgentCase: + """Convert an OpenAI Agents SDK RunResult using its public run items.""" + items = member(result, "new_items") + if not isinstance(items, Sequence): + raise HarnessIntegrationError( + "OpenAI Agents result does not expose a new_items sequence" + ) + outputs: dict[str, Any] = {} + for item in items: + if member(item, "type") == "tool_call_output_item": + identifier = _call_id(item) + if identifier: + outputs[identifier] = json_value(member(item, "output")) + + calls: list[ToolCallRecord] = [] + for index, item in enumerate(items): + if member(item, "type") != "tool_call_item": + continue + raw = _raw(item) + name = member(item, "tool_name") or member(raw, "name") + if not isinstance(name, str) or not name: + raise HarnessIntegrationError( + f"OpenAI Agents tool call {index} has no stable name" + ) + arguments = member(raw, "arguments", {}) + identifier = _call_id(item) + calls.append( + ToolCallRecord( + name=name, + input_parameters=json_arguments( + arguments, location=f"OpenAI Agents tool call {name!r}" + ), + output=outputs.get(identifier) if identifier else None, + description=member(item, "description"), + ) + ) + if not calls: + raise HarnessIntegrationError("OpenAI Agents result contains no tool calls") + expected = approved_expected_calls( + calls, expected_tools, approve_observed=approve_observed + ) + return AgentCase( + case_id=case_id, + input=input, + actual_output=text_output(member(result, "final_output")), + expected_output=expected_output, + tools_called=tuple(calls), + expected_tools=expected, + tags=tuple(tags), + metadata={"harness": "openai-agents"}, + ) + + +def tool_specs( + tools: Sequence[Any], *, side_effecting: Sequence[str] = () +) -> tuple[ToolSpec, ...]: + """Convert OpenAI Agents FunctionTool-compatible objects.""" + side_effect_names = set(side_effecting) + return tuple( + tool_spec_from_object( + tool, + framework="OpenAI Agents", + side_effecting=str(member(tool, "name")) in side_effect_names, + ) + for tool in tools + ) diff --git a/tests/test_harness_integrations.py b/tests/test_harness_integrations.py new file mode 100644 index 0000000..1ff2664 --- /dev/null +++ b/tests/test_harness_integrations.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from mendmark.agent_cases import ToolCallRecord +from mendmark.cli import main +from mendmark.equip import EquipError, agent_prompt, detect_frameworks, equip_project +from mendmark.integrations import CausalCaseBuilder, HarnessIntegrationError, write_suite +from mendmark.integrations.crewai import CrewAIRecorder, case_from_events +from mendmark.integrations.langchain import case_from_messages, tool_specs as lc_tools +from mendmark.integrations.openai_agents import ( + case_from_result, + tool_specs as openai_tools, +) +from mendmark.json_adapter import load_json_suite + + +@dataclass +class FakeTool: + name: str + description: str + args_schema: dict[str, object] + + +def test_langchain_messages_pair_tool_results_and_write_valid_suite(tmp_path: Path) -> None: + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call-1", "name": "lookup_order", "args": {"order_id": "104"}} + ], + }, + { + "role": "tool", + "tool_call_id": "call-1", + "content": {"status": "paid"}, + }, + {"role": "assistant", "content": "Order 104 is paid."}, + ] + case = case_from_messages( + messages, + case_id="paid-order", + input="Check order 104", + expected_output="Order 104 is paid.", + approve_observed=True, + ) + tools = lc_tools( + [ + FakeTool( + "lookup_order", + "Look up one order", + { + "type": "object", + "properties": {"order_id": {"type": "string"}}, + "required": ["order_id"], + }, + ) + ] + ) + path = write_suite(tmp_path / "suite.json", [case], tools) + + suite = load_json_suite(path) + assert suite.cases[0].tools_called == suite.cases[0].expected_tools + assert suite.cases[0].tools_called[0].output == {"status": "paid"} + assert suite.cases[0].metadata == {"harness": "langchain-langgraph"} + + +def test_harness_converter_never_implicitly_approves_observed_trace() -> None: + with pytest.raises(HarnessIntegrationError, match="expected tool calls are required"): + case_from_messages( + [ + { + "role": "assistant", + "tool_calls": [{"id": "1", "name": "charge", "args": {}}], + }, + {"role": "tool", "tool_call_id": "1", "content": "ok"}, + ], + case_id="charge", + input="charge it", + expected_output=None, + ) + + +def test_openai_agents_result_uses_public_run_items() -> None: + agent = SimpleNamespace(name="billing") + call = SimpleNamespace( + type="tool_call_item", + agent=agent, + tool_name="refund_order", + description="Refund a paid order", + call_id="call-2", + raw_item={ + "name": "refund_order", + "call_id": "call-2", + "arguments": '{"order_id":"104","amount":29.99}', + }, + ) + output = SimpleNamespace( + type="tool_call_output_item", + call_id="call-2", + output={"status": "accepted"}, + raw_item={"call_id": "call-2"}, + ) + result = SimpleNamespace( + new_items=[call, output], final_output="Refund accepted." + ) + + case = case_from_result( + result, + case_id="refund", + input="Refund order 104", + expected_output="Refund accepted.", + approve_observed=True, + ) + + assert case.tools_called[0] == ToolCallRecord( + name="refund_order", + input_parameters={"order_id": "104", "amount": 29.99}, + output={"status": "accepted"}, + description="Refund a paid order", + ) + assert case.metadata == {"harness": "openai-agents"} + + +def test_openai_tool_schema_and_side_effect_are_preserved() -> None: + tool = SimpleNamespace( + name="send_email", + description="Send an email", + params_json_schema={ + "type": "object", + "properties": {"to": {"type": "string"}}, + "required": ["to"], + }, + ) + converted = openai_tools([tool], side_effecting=["send_email"]) + assert converted[0].side_effecting is True + assert converted[0].input_schema["required"] == ["to"] + + +def test_crewai_events_and_recorder_capture_public_event_fields() -> None: + recorder = CrewAIRecorder() + recorder.record( + SimpleNamespace( + type="tool_usage_finished", + tool_name="search", + tool_args='{"query":"mendmark"}', + output={"hits": 3}, + ) + ) + recorder.record( + SimpleNamespace(type="agent_execution_completed", output="Found three results.") + ) + case = recorder.case( + case_id="search", + input="Search for Mendmark", + expected_output="Found three results.", + approve_observed=True, + ) + assert case.tools_called[0].input_parameters == {"query": "mendmark"} + assert case.tools_called[0].output == {"hits": 3} + assert recorder.events + recorder.clear() + assert recorder.events == () + + +def test_crewai_converter_rejects_invalid_json_arguments() -> None: + with pytest.raises(HarnessIntegrationError, match="invalid JSON arguments"): + case_from_events( + [ + SimpleNamespace( + type="tool_usage_finished", + tool_name="search", + tool_args="{bad", + output="none", + ) + ], + case_id="bad", + input="bad", + expected_output=None, + approve_observed=True, + ) + + +def test_write_suite_is_atomic_and_refuses_overwrite(tmp_path: Path) -> None: + case = case_from_messages( + [ + {"role": "assistant", "tool_calls": [{"id": "1", "name": "x", "args": {}}]}, + {"role": "tool", "tool_call_id": "1", "content": "ok"}, + ], + case_id="one", + input="x", + expected_output=None, + approve_observed=True, + ) + tools = lc_tools([FakeTool("x", "x", {"type": "object"})]) + destination = write_suite(tmp_path / "suite.json", [case], tools) + original = destination.read_bytes() + with pytest.raises(HarnessIntegrationError, match="refusing to overwrite"): + write_suite(destination, [case], tools) + assert destination.read_bytes() == original + + +def test_equip_detects_multiple_harnesses_and_is_idempotent(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\ndependencies=["langgraph", "openai-agents"]\n', encoding="utf-8" + ) + assert detect_frameworks(tmp_path) == ("langgraph", "openai-agents") + + frameworks, created, unchanged = equip_project(tmp_path) + assert frameworks == ("langgraph", "openai-agents") + assert len(created) == 5 + assert unchanged == () + _, created_again, unchanged_again = equip_project(tmp_path) + assert created_again == () + assert len(unchanged_again) == 5 + assert "approve_observed=True" in ( + tmp_path / ".mendmark" / "agent-setup.md" + ).read_text(encoding="utf-8") + + +def test_equip_dry_run_prompt_and_cli(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + result = main( + [ + "equip", + "--framework", + "crewai", + "--project-root", + str(tmp_path), + "--dry-run", + ] + ) + assert result == 0 + assert not (tmp_path / ".mendmark").exists() + assert "Would create: .mendmark/evaluator.py" in capsys.readouterr().out + + result = main(["equip", "--print-agent-prompt", "--project-root", str(tmp_path)]) + assert result == 0 + assert "mendmark equip --framework auto" in capsys.readouterr().out + assert ".mendmark/agent-setup.md" in agent_prompt() + + +def test_equip_refuses_conflicts_and_symlink_escape(tmp_path: Path) -> None: + generated = tmp_path / ".mendmark" + generated.mkdir() + (generated / "evaluator.py").write_text("customer code\n", encoding="utf-8") + with pytest.raises(EquipError, match="refusing to overwrite"): + equip_project(tmp_path, framework="generic") + + clean = tmp_path / "clean" + outside = tmp_path / "outside" + clean.mkdir() + outside.mkdir() + (clean / ".mendmark").symlink_to(outside, target_is_directory=True) + with pytest.raises(EquipError, match="refusing to overwrite"): + equip_project(clean, framework="generic") + + +def test_generated_evaluator_completes_an_offline_audit(tmp_path: Path) -> None: + equip_project(tmp_path, framework="langgraph") + case = case_from_messages( + [ + { + "role": "assistant", + "tool_calls": [{"id": "1", "name": "lookup", "args": {"id": "7"}}], + }, + {"role": "tool", "tool_call_id": "1", "content": {"status": "ok"}}, + {"role": "assistant", "content": "Found it."}, + ], + case_id="lookup", + input="Find 7", + expected_output="Found it.", + approve_observed=True, + ) + tools = lc_tools( + [ + FakeTool( + "lookup", + "Look up an item", + { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + ) + ] + ) + write_suite(tmp_path / ".mendmark" / "suite.json", [case], tools) + result = main( + [ + "audit-json", + str(tmp_path / ".mendmark" / "suite.json"), + "--evaluator-command", + f"python3 {tmp_path / '.mendmark' / 'evaluator.py'}", + "--output", + str(tmp_path / "report.json"), + ] + ) + assert result == 0 + report = json.loads((tmp_path / "report.json").read_text(encoding="utf-8")) + assert report["summary"]["survived"] == 0 + assert report["gate"]["passed"] is True + + +def test_causal_builder_preserves_parallel_dependencies_and_writes_v2( + tmp_path: Path, +) -> None: + builder = ( + CausalCaseBuilder( + case_id="parallel", input="review", root_agent_id="supervisor" + ) + .agent("supervisor") + .agent("billing", allowed_tools=["lookup"]) + .agent("risk", allowed_tools=["risk"]) + .delegation("d-billing", "supervisor", "billing") + .delegation("d-risk", "supervisor", "risk") + .tool_call( + "lookup", + "billing", + "lookup", + input_parameters={"id": "7"}, + output={"paid": True}, + depends_on=["d-billing"], + ) + .tool_call( + "risk", + "risk", + "risk", + input_parameters={"id": "7"}, + output={"level": "low"}, + depends_on=["d-risk"], + ) + .result("billing-result", "billing", "supervisor", depends_on=["lookup"]) + .result("risk-result", "risk", "supervisor", depends_on=["risk"]) + .message( + "aggregate", + "supervisor", + depends_on=["billing-result", "risk-result"], + ) + ) + with pytest.raises(HarnessIntegrationError, match="expected causal events"): + builder.build(actual_output="ok", expected_output="ok") + case = builder.build( + actual_output="ok", expected_output="ok", approve_observed=True + ) + tools = lc_tools( + [ + FakeTool("lookup", "lookup", {"type": "object"}), + FakeTool("risk", "risk", {"type": "object"}), + ] + ) + path = write_suite(tmp_path / "multi.json", [case], tools) + suite = load_json_suite(path) + assert suite.schema_version == "2.0" + aggregate = suite.cases[0].events[-1] + assert set(aggregate.depends_on) == {"billing-result", "risk-result"} + + +def test_causal_builder_rejects_unauthorized_tool_use() -> None: + builder = ( + CausalCaseBuilder(case_id="bad", input="bad", root_agent_id="root") + .agent("root") + .tool_call("charge", "root", "charge", input_parameters={}) + ) + with pytest.raises(HarnessIntegrationError, match="outside agent .* allow-list"): + builder.build( + actual_output="bad", expected_output=None, approve_observed=True + ) diff --git a/tests/test_harness_live_compatibility.py b/tests/test_harness_live_compatibility.py new file mode 100644 index 0000000..b19a538 --- /dev/null +++ b/tests/test_harness_live_compatibility.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import os +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from mendmark.integrations.crewai import case_from_events, tool_specs as crewai_tools +from mendmark.integrations.langchain import case_from_messages, tool_specs as lc_tools +from mendmark.integrations.openai_agents import ( + case_from_result, + tool_specs as openai_tools, +) + + +PROFILE = os.environ.get("MENDMARK_HARNESS") + + +@pytest.mark.skipif(PROFILE != "langgraph", reason="LangGraph compatibility profile") +def test_current_langchain_core_public_objects() -> None: + from langchain_core.messages import AIMessage, ToolMessage + from langchain_core.tools import tool + + @tool + def lookup_order(order_id: str) -> str: + """Look up an order.""" + return order_id + + messages = [ + AIMessage( + content="", + tool_calls=[{"name": "lookup_order", "args": {"order_id": "7"}, "id": "c1"}], + ), + ToolMessage(content='{"status":"paid"}', tool_call_id="c1"), + AIMessage(content="Paid."), + ] + case = case_from_messages( + messages, + case_id="live", + input="check 7", + expected_output="Paid.", + approve_observed=True, + ) + assert case.tools_called[0].name == "lookup_order" + assert lc_tools([lookup_order])[0].input_schema["required"] == ["order_id"] + + +@pytest.mark.skipif(PROFILE != "openai-agents", reason="OpenAI Agents compatibility profile") +def test_current_openai_agents_public_objects() -> None: + from agents import Agent, ToolCallItem, ToolCallOutputItem, function_tool + + @function_tool + def lookup_order(order_id: str) -> str: + """Look up an order.""" + return order_id + + agent = Agent(name="billing", tools=[lookup_order]) + call = ToolCallItem( + agent=agent, + raw_item={ + "type": "function_call", + "name": "lookup_order", + "arguments": '{"order_id":"7"}', + "call_id": "c1", + }, + ) + output = ToolCallOutputItem( + agent=agent, + raw_item={"type": "function_call_output", "call_id": "c1", "output": "paid"}, + output="paid", + ) + case = case_from_result( + SimpleNamespace(new_items=[call, output], final_output="Paid."), + case_id="live", + input="check 7", + expected_output="Paid.", + approve_observed=True, + ) + assert case.tools_called[0].output == "paid" + assert openai_tools([lookup_order])[0].input_schema["required"] == ["order_id"] + + +@pytest.mark.skipif(PROFILE != "crewai", reason="CrewAI compatibility profile") +def test_current_crewai_public_objects() -> None: + from crewai.events.types.tool_usage_events import ToolUsageFinishedEvent + from crewai.tools import BaseTool + + class LookupOrder(BaseTool): + name: str = "lookup_order" + description: str = "Look up an order" + + def _run(self, order_id: str) -> str: + return order_id + + tool = LookupOrder() + now = datetime.now(timezone.utc) + event = ToolUsageFinishedEvent( + tool_name="lookup_order", + tool_args={"order_id": "7"}, + output="paid", + started_at=now, + finished_at=now, + ) + case = case_from_events( + [event, SimpleNamespace(type="agent_execution_completed", output="Paid.")], + case_id="live", + input="check 7", + expected_output="Paid.", + approve_observed=True, + ) + assert case.tools_called[0].output == "paid" + assert crewai_tools([tool])[0].input_schema["type"] == "object"