contract4agents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. contract4agents/__init__.py +7 -0
  2. contract4agents/__main__.py +6 -0
  3. contract4agents/adapters/__init__.py +0 -0
  4. contract4agents/adapters/openai.py +169 -0
  5. contract4agents/ast.py +135 -0
  6. contract4agents/cli.py +143 -0
  7. contract4agents/compiler/__init__.py +310 -0
  8. contract4agents/diagnostics.py +39 -0
  9. contract4agents/docscheck.py +69 -0
  10. contract4agents/evaluation.py +84 -0
  11. contract4agents/expressions/__init__.py +31 -0
  12. contract4agents/expressions/_eval.py +152 -0
  13. contract4agents/expressions/_grammar.py +212 -0
  14. contract4agents/expressions/_model.py +29 -0
  15. contract4agents/expressions/_refs.py +18 -0
  16. contract4agents/expressions/_trace_ops.py +56 -0
  17. contract4agents/fixtures/__init__.py +123 -0
  18. contract4agents/fixtures/_artifacts.py +65 -0
  19. contract4agents/fixtures/_execution.py +83 -0
  20. contract4agents/fixtures/_models.py +75 -0
  21. contract4agents/fixtures/_reports.py +72 -0
  22. contract4agents/monitor.py +46 -0
  23. contract4agents/parser/__init__.py +57 -0
  24. contract4agents/parser/_grammar.py +91 -0
  25. contract4agents/parser/_transformer.py +323 -0
  26. contract4agents/parser/_values.py +61 -0
  27. contract4agents/runtime/__init__.py +53 -0
  28. contract4agents/runtime/_datasources.py +301 -0
  29. contract4agents/runtime/_errors.py +64 -0
  30. contract4agents/runtime/_tools.py +59 -0
  31. contract4agents/runtime/_trace.py +41 -0
  32. contract4agents/runtime/_trace_io.py +49 -0
  33. contract4agents/runtime/_utils.py +23 -0
  34. contract4agents/schema.py +86 -0
  35. contract4agents/semantics.py +335 -0
  36. contract4agents/visualization/__init__.py +30 -0
  37. contract4agents/visualization/_artifacts.py +23 -0
  38. contract4agents/visualization/_graph.py +185 -0
  39. contract4agents/visualization/_html.py +508 -0
  40. contract4agents/visualization/_mermaid.py +82 -0
  41. contract4agents/visualization/_types.py +60 -0
  42. contract4agents/visualization/_utils.py +39 -0
  43. contract4agents-0.1.0.dist-info/METADATA +132 -0
  44. contract4agents-0.1.0.dist-info/RECORD +47 -0
  45. contract4agents-0.1.0.dist-info/WHEEL +4 -0
  46. contract4agents-0.1.0.dist-info/entry_points.txt +5 -0
  47. contract4agents-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,7 @@
1
+ """Contract4Agents Python package."""
2
+
3
+ from contract4agents.compiler import compile_project
4
+ from contract4agents.parser import parse_file, parse_project
5
+ from contract4agents.semantics import analyze_project
6
+
7
+ __all__ = ["analyze_project", "compile_project", "parse_file", "parse_project"]
@@ -0,0 +1,6 @@
1
+ """Module entry point for the Contract4Agents CLI."""
2
+
3
+ from contract4agents.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
File without changes
@@ -0,0 +1,169 @@
1
+ """OpenAI Agents SDK adapter.
2
+
3
+ The adapter is intentionally thin: Contract4Agents compiles to provider-neutral
4
+ manifests first, and this module projects those manifests onto OpenAI's SDK.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from contract4agents.compiler import AgentManifest
14
+ from contract4agents.runtime import TraceRecorder
15
+
16
+ _RunHooksBase: type[Any]
17
+ try:
18
+ from agents import RunHooks as _ImportedRunHooks
19
+
20
+ _RunHooksBase = _ImportedRunHooks
21
+ except Exception: # noqa: BLE001 - optional adapter import boundary.
22
+ _RunHooksBase = object
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class OpenAIAdapterResult:
27
+ final_output: Any
28
+ last_agent: str | None
29
+ raw_result: Any
30
+
31
+
32
+ class OpenAIAdapterUnavailable(RuntimeError):
33
+ pass
34
+
35
+
36
+ def openai_tool_name(contract_name: str) -> str:
37
+ """Convert a Contract4Agents capability name into an OpenAI-safe tool name."""
38
+ return contract_name.replace(".", "__")
39
+
40
+
41
+ def contract_tool_name(openai_name: str) -> str:
42
+ """Convert a generated OpenAI tool name back into the Contract4Agents capability name."""
43
+ return openai_name.replace("__", ".")
44
+
45
+
46
+ class OpenAITraceHooks(_RunHooksBase): # type: ignore[misc]
47
+ """Minimal hook object that normalizes Agents SDK lifecycle events to Contract4Agents traces."""
48
+
49
+ def __init__(self, trace: TraceRecorder) -> None:
50
+ super().__init__()
51
+ self.trace = trace
52
+
53
+ async def on_agent_start(self, _context: Any, agent: Any) -> None:
54
+ self.trace.record("agent.started", agent=getattr(agent, "name", str(agent)))
55
+
56
+ async def on_agent_end(self, _context: Any, agent: Any, output: Any) -> None:
57
+ self.trace.record("agent.completed", agent=getattr(agent, "name", str(agent)), output=_serializable(output))
58
+
59
+ async def on_handoff(self, _context: Any, from_agent: Any, to_agent: Any) -> None:
60
+ self.trace.record(
61
+ "agent.handoff",
62
+ from_agent=getattr(from_agent, "name", str(from_agent)),
63
+ to_agent=getattr(to_agent, "name", str(to_agent)),
64
+ )
65
+
66
+ async def on_tool_start(self, _context: Any, agent: Any, tool: Any) -> None:
67
+ self.trace.record(
68
+ "tool.started",
69
+ agent=getattr(agent, "name", str(agent)),
70
+ tool=contract_tool_name(getattr(tool, "name", str(tool))),
71
+ )
72
+
73
+ async def on_tool_end(self, _context: Any, agent: Any, tool: Any, result: str) -> None:
74
+ self.trace.record(
75
+ "tool.completed",
76
+ agent=getattr(agent, "name", str(agent)),
77
+ tool=contract_tool_name(getattr(tool, "name", str(tool))),
78
+ result=_serializable(result),
79
+ )
80
+
81
+ async def on_llm_start(self, _context: Any, agent: Any, _system_prompt: str | None, input_items: list[Any]) -> None:
82
+ self.trace.record("llm.started", agent=getattr(agent, "name", str(agent)), input_count=len(input_items))
83
+
84
+ async def on_llm_end(self, _context: Any, agent: Any, _response: Any) -> None:
85
+ self.trace.record("llm.completed", agent=getattr(agent, "name", str(agent)))
86
+
87
+
88
+ def build_openai_agent(
89
+ manifest: AgentManifest,
90
+ instructions: str,
91
+ tools: list[Any] | None = None,
92
+ handoffs: list[Any] | None = None,
93
+ output_type: Any | None = None,
94
+ hooks: Any | None = None,
95
+ input_guardrails: list[Any] | None = None,
96
+ ) -> Any:
97
+ try:
98
+ from agents import Agent
99
+ except Exception as exc: # noqa: BLE001 - optional adapter import boundary.
100
+ raise OpenAIAdapterUnavailable("openai-agents is not installed") from exc
101
+ kwargs: dict[str, Any] = {
102
+ "name": manifest["agent"],
103
+ "instructions": instructions,
104
+ "model": manifest.get("model", "gpt-5.5"),
105
+ "tools": tools or [],
106
+ "handoffs": handoffs or [],
107
+ }
108
+ if output_type is not None:
109
+ kwargs["output_type"] = output_type
110
+ if hooks is not None:
111
+ kwargs["hooks"] = hooks
112
+ if input_guardrails is not None:
113
+ kwargs["input_guardrails"] = input_guardrails
114
+ return Agent(**kwargs)
115
+
116
+
117
+ async def run_openai_agent(
118
+ agent: Any,
119
+ user_input: str,
120
+ *,
121
+ context: Any | None = None,
122
+ max_turns: int | None = 10,
123
+ hooks: Any | None = None,
124
+ ) -> OpenAIAdapterResult:
125
+ try:
126
+ from agents import Runner
127
+ except Exception as exc: # noqa: BLE001 - optional adapter import boundary.
128
+ raise OpenAIAdapterUnavailable("openai-agents is not installed") from exc
129
+ result = await Runner.run(agent, user_input, context=context, max_turns=max_turns, hooks=hooks)
130
+ last_agent = getattr(getattr(result, "last_agent", None), "name", None)
131
+ return OpenAIAdapterResult(getattr(result, "final_output", None), last_agent, result)
132
+
133
+
134
+ class OpenAISemanticJudge:
135
+ def __init__(self, model: str = "gpt-5.5", api_key_env: str = "OPENAI_API_KEY") -> None:
136
+ self.model = model
137
+ self.api_key_env = api_key_env
138
+
139
+ async def judge(self, *, output: dict[str, Any], criterion: str) -> bool:
140
+ if not os.getenv(self.api_key_env):
141
+ raise OpenAIAdapterUnavailable(f"{self.api_key_env} is not set")
142
+ try:
143
+ from openai import AsyncOpenAI
144
+ except Exception as exc: # noqa: BLE001 - optional adapter import boundary.
145
+ raise OpenAIAdapterUnavailable("openai package is not installed") from exc
146
+ client = AsyncOpenAI()
147
+ response = await client.responses.create(
148
+ model=self.model,
149
+ input=[
150
+ {
151
+ "role": "system",
152
+ "content": "Return only PASS or FAIL. Evaluate whether the output satisfies the criterion.",
153
+ },
154
+ {
155
+ "role": "user",
156
+ "content": f"Criterion: {criterion}\nOutput: {output}",
157
+ },
158
+ ],
159
+ )
160
+ text = getattr(response, "output_text", "")
161
+ return str(text).strip().upper() == "PASS"
162
+
163
+
164
+ def _serializable(value: Any) -> Any:
165
+ if hasattr(value, "model_dump"):
166
+ return value.model_dump()
167
+ if isinstance(value, dict | list | str | int | float | bool) or value is None:
168
+ return value
169
+ return str(value)
contract4agents/ast.py ADDED
@@ -0,0 +1,135 @@
1
+ """AST nodes for Contract4Agents source files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SourceSpan:
12
+ path: Path
13
+ line: int
14
+ column: int = 1
15
+
16
+ def display(self) -> str:
17
+ return f"{self.path}:{self.line}:{self.column}"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class FieldDef:
22
+ name: str
23
+ type_name: str
24
+ nullable: bool = False
25
+ default: str | None = None
26
+ span: SourceSpan | None = None
27
+
28
+ @property
29
+ def normalized_type(self) -> str:
30
+ return self.type_name.rstrip("?").strip()
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TypeDef:
35
+ name: str
36
+ fields: list[FieldDef]
37
+ span: SourceSpan
38
+
39
+
40
+ Permission = Literal["available", "preapproved", "requires_approval", "denied", "sandboxed"]
41
+ UseKind = Literal["tool", "agent", "datasource"]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class UseDecl:
46
+ kind: UseKind
47
+ name: str
48
+ source: str
49
+ permission: Permission = "available"
50
+ span: SourceSpan | None = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class DatasourceDef:
55
+ name: str
56
+ python: str
57
+ requires: list[str]
58
+ produces: str
59
+ render: str = "markdown"
60
+ cache: str = "run"
61
+ span: SourceSpan | None = None
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class AgentDef:
66
+ name: str
67
+ parameters: list[FieldDef]
68
+ return_type: str
69
+ uses: list[UseDecl]
70
+ attributes: dict[str, Any]
71
+ span: SourceSpan
72
+
73
+ def list_attr(self, key: str) -> list[str]:
74
+ value = self.attributes.get(key, [])
75
+ return value if isinstance(value, list) else []
76
+
77
+ def text_attr(self, key: str) -> str:
78
+ value = self.attributes.get(key, "")
79
+ return value if isinstance(value, str) else ""
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class EvalCase:
84
+ name: str
85
+ agent: str
86
+ givens: dict[str, str]
87
+ expects: list[str]
88
+ semantic_expects: list[str]
89
+ span: SourceSpan
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class MonitorDef:
94
+ name: str
95
+ agent: str
96
+ severity: str
97
+ condition: str
98
+ expectation: str
99
+ span: SourceSpan
100
+
101
+
102
+ @dataclass
103
+ class ContractModule:
104
+ path: Path
105
+ types: list[TypeDef] = field(default_factory=list)
106
+ datasources: list[DatasourceDef] = field(default_factory=list)
107
+ agents: list[AgentDef] = field(default_factory=list)
108
+ evals: list[EvalCase] = field(default_factory=list)
109
+ monitors: list[MonitorDef] = field(default_factory=list)
110
+
111
+
112
+ @dataclass
113
+ class ContractProject:
114
+ root: Path
115
+ modules: list[ContractModule]
116
+
117
+ @property
118
+ def types(self) -> dict[str, TypeDef]:
119
+ return {item.name: item for module in self.modules for item in module.types}
120
+
121
+ @property
122
+ def datasources(self) -> dict[str, DatasourceDef]:
123
+ return {item.name: item for module in self.modules for item in module.datasources}
124
+
125
+ @property
126
+ def agents(self) -> dict[str, AgentDef]:
127
+ return {item.name: item for module in self.modules for item in module.agents}
128
+
129
+ @property
130
+ def evals(self) -> list[EvalCase]:
131
+ return [item for module in self.modules for item in module.evals]
132
+
133
+ @property
134
+ def monitors(self) -> list[MonitorDef]:
135
+ return [item for module in self.modules for item in module.monitors]
contract4agents/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ """Click CLI for Contract4Agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from contract4agents.compiler import build_artifacts, compile_project
11
+ from contract4agents.diagnostics import ContractError, raise_if_errors
12
+ from contract4agents.docscheck import check_docs
13
+ from contract4agents.fixtures import FixtureReport, run_fixture_project_sync
14
+ from contract4agents.monitor import MonitorRule, run_monitors
15
+ from contract4agents.parser import parse_project
16
+ from contract4agents.runtime._trace_io import TraceFileError, load_trace_jsonl
17
+ from contract4agents.semantics import analyze_project
18
+ from contract4agents.visualization import build_visualization_graph, write_visualization_artifacts
19
+
20
+
21
+ @click.group()
22
+ def main() -> None:
23
+ """Compile, validate, and evaluate Contract4Agents projects."""
24
+
25
+
26
+ @main.command()
27
+ @click.argument("root", type=click.Path(path_type=Path), default=".", required=False)
28
+ def check(root: Path) -> None:
29
+ """Parse and semantically validate a Contract4Agents project."""
30
+ try:
31
+ project = parse_project(root)
32
+ result = analyze_project(project)
33
+ for diagnostic in result.diagnostics:
34
+ click.echo(diagnostic.format(), err=diagnostic.severity == "error")
35
+ if not result.ok:
36
+ raise click.ClickException("Contract4Agents check failed")
37
+ click.echo("Contract4Agents check passed")
38
+ except ContractError as exc:
39
+ _print_contract_error(exc)
40
+
41
+
42
+ @main.command("compile")
43
+ @click.argument("root", type=click.Path(path_type=Path), default=".", required=False)
44
+ @click.option("--out", "output_dir", type=click.Path(path_type=Path), default=".contract/build")
45
+ @click.option("--check", "check_mode", is_flag=True, help="Fail if generated artifacts are stale.")
46
+ def compile_cmd(root: Path, output_dir: Path, check_mode: bool) -> None:
47
+ """Compile a Contract4Agents project into provider-neutral artifacts."""
48
+ try:
49
+ compile_project(root, output_dir, check=check_mode)
50
+ click.echo("Contract4Agents compile passed")
51
+ except ContractError as exc:
52
+ _print_contract_error(exc)
53
+
54
+
55
+ @main.command("visualize")
56
+ @click.argument("root", type=click.Path(path_type=Path), default=".", required=False)
57
+ @click.option("--out", "output_dir", type=click.Path(path_type=Path), default=".contract/build/visualization")
58
+ def visualize_cmd(root: Path, output_dir: Path) -> None:
59
+ """Generate static HTML visualization artifacts.
60
+
61
+ ROOT defaults to the current directory. The default output directory is
62
+ .contract/build/visualization.
63
+ """
64
+ try:
65
+ project = parse_project(root)
66
+ raise_if_errors(analyze_project(project).diagnostics)
67
+ artifacts = build_artifacts(project)
68
+ graph = build_visualization_graph(project, artifacts)
69
+ write_visualization_artifacts(graph, output_dir)
70
+ click.echo(f"Contract4Agents visualization written to {output_dir}")
71
+ except ContractError as exc:
72
+ _print_contract_error(exc)
73
+
74
+
75
+ @main.command("eval")
76
+ @click.argument("root", type=click.Path(path_type=Path), default=".", required=False)
77
+ def eval_cmd(root: Path) -> None:
78
+ """Run local evals for a fixture.json project."""
79
+ if not (root / "fixture.json").exists():
80
+ raise click.ClickException("Contract4Agents eval requires ROOT/fixture.json")
81
+ try:
82
+ report = run_fixture_project_sync(project_root=root, run_root=root / ".contract" / "runs" / "last")
83
+ except Exception as exc:
84
+ raise click.ClickException(f"Contract4Agents fixture eval failed: {exc}") from exc
85
+ _print_fixture_report(report)
86
+ if not report.passed:
87
+ raise click.ClickException("Contract4Agents eval failed")
88
+
89
+
90
+ def _print_fixture_report(report: FixtureReport) -> None:
91
+ click.echo(f"Fixture eval {'passed' if report.passed else 'failed'}: {len(report.starts)} starts")
92
+ for start in report.starts:
93
+ status = "PASS" if start.passed and not start.monitor_violations else "FAIL"
94
+ click.echo(f"{status} {start.start_id}")
95
+ for failure in start.failures:
96
+ click.echo(f" {failure}")
97
+ for violation in start.monitor_violations:
98
+ click.echo(f" monitor: {violation}")
99
+ for skipped in start.skipped_semantic:
100
+ click.echo(f" semantic skipped: {skipped}")
101
+
102
+
103
+ @main.command()
104
+ @click.argument("root", type=click.Path(path_type=Path), default=".", required=False)
105
+ @click.option("--trace", "trace_path", type=click.Path(path_type=Path), required=True, help="Trace JSONL file.")
106
+ def monitor(root: Path, trace_path: Path) -> None:
107
+ """Run project monitors against a trace JSONL file."""
108
+ artifacts = compile_project(root)
109
+ try:
110
+ trace = load_trace_jsonl(trace_path)
111
+ except TraceFileError as exc:
112
+ raise click.ClickException(str(exc)) from exc
113
+ rules = [
114
+ MonitorRule(item["name"], item["agent"], item["severity"], item["when"], item["expect"])
115
+ for item in artifacts["monitors"]
116
+ ]
117
+ violations = run_monitors(rules, trace)
118
+ for violation in violations:
119
+ click.echo(f"{violation.severity.upper()} {violation.rule}: {violation.message}")
120
+ if violations:
121
+ raise click.ClickException("Contract4Agents monitor failed")
122
+ click.echo("Contract4Agents monitor passed")
123
+
124
+
125
+ @main.command("docs-check")
126
+ @click.argument("root", type=click.Path(path_type=Path), default=".", required=False)
127
+ def docs_check(root: Path) -> None:
128
+ """Check required documentation files and local markdown links.
129
+
130
+ ROOT defaults to the current directory.
131
+ """
132
+ diagnostics = check_docs(root)
133
+ for diagnostic in diagnostics:
134
+ click.echo(diagnostic.format(), err=True)
135
+ if diagnostics:
136
+ raise click.ClickException("Docs check failed")
137
+ click.echo("Docs check passed")
138
+
139
+
140
+ def _print_contract_error(exc: ContractError) -> None:
141
+ for diagnostic in exc.diagnostics:
142
+ click.echo(diagnostic.format(), err=True)
143
+ sys.exit(1)