From 79c27f0c73a6c748be2310b7cdd0623937330ccb Mon Sep 17 00:00:00 2001 From: pateti Chandu Date: Fri, 25 Sep 2026 13:47:41 +0530 Subject: [PATCH 1/3] Add optional Rich CLI and tool-protection decorator --- README.md | 196 +++++++++++++++++++++++++++---------- opendecision/__main__.py | 13 +++ opendecision/cli.py | 111 +++++++++++++++++++++ opendecision/tool_guard.py | 69 +++++++++++++ pyproject.toml | 35 ++----- tests/test_cli.py | 35 +++++++ tests/test_tool_guard.py | 46 +++++++++ 7 files changed, 430 insertions(+), 75 deletions(-) create mode 100644 opendecision/__main__.py create mode 100644 opendecision/cli.py create mode 100644 opendecision/tool_guard.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_tool_guard.py diff --git a/README.md b/README.md index 5fd5183..d5b49d8 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,173 @@ # OpenDecision -An auditable, typed decision layer for AI agents. +**A framework-agnostic decision layer for AI agents.** -OpenDecision places explicit contracts, deterministic rules, provider fallback, human review, and privacy-preserving audit traces between an agent and consequential actions. It is a control-layer library, not a safety guarantee. +OpenDecision places a typed, testable contract between an AI agent and the action it wants to take. It uses [Laya](https://github.com/NandhaKishorM/laya) for fast `choice`, `score`, and `noul` decisions without adding another text-generation step. -## Production-foundation preview +> Status: early MVP. Do not treat uncalibrated model confidence as a production safety guarantee. -Version 0.2 adds: +## Why OpenDecision? -- strict decision-contract and provider-output validation; -- deterministic rules plus ordered provider fallback chains; -- fail-safe `raise`, `review`, and `block` behavior; -- policy IDs, semantic versions, risk levels, defaults, and inheritance; -- context hashes, decision IDs, provider attempts, and JSONL audit sinks; -- an in-memory human-review workflow with loop protection; -- reproducible accuracy, confusion, and Brier-score evaluation primitives; -- coverage, lint, strict typing, and Python 3.10–3.13 CI gates. +Agents repeatedly need to decide whether to continue, stop, call a tool, ask a person, retrieve more context, or escalate. OpenDecision makes those branches explicit and reusable: -## Example +- typed decision contracts instead of free-form generated text; +- normalized results across decision providers; +- community-maintained YAML decision packs; +- adapters that do not couple the core SDK to one agent framework; +- optional Laya loading, so importing OpenDecision never downloads a model. -```python -from opendecision import ( - DecisionContext, - DecisionGuard, - JsonlAuditSink, - ProviderChain, - Rule, - RuleProvider, -) +## Install -rules = RuleProvider( - [Rule(field="command", operator="contains", value="rm -rf", decision="block")], -) -providers = ProviderChain([rules], terminal_answer={"choice": "review"}) +```bash +pip install -e ".[laya]" +``` -guard = DecisionGuard( - providers, - audit_sink=JsonlAuditSink("audit/decisions.jsonl"), - failure_mode="review", -) +To enable the Rich-powered CLI output: + +```bash +pip install -e ".[rich]" +``` + +Laya and OpenDecision require Python 3.10 or newer. For the examples: + +```bash +pip install -e ".[laya,langgraph,fastapi]" +``` + +## Quick start + +```python +from opendecision import DecisionGuard +guard = DecisionGuard() result = guard.decide( - {"command": "rm -rf /tmp/cache"}, - { + state={"tool": "send_email", "recipient": "customer@example.com"}, + question={ "type": "choice", - "instructions": "Should this command execute?", + "instructions": "Should the agent execute this tool action?", "options": { - "allow": "Authorized and low risk", - "review": "Needs human approval", - "block": "Unauthorized or destructive", + "allow": "Proceed automatically", + "review": "Require human approval", + "block": "Stop the action", }, }, - context=DecisionContext( - actor="agent:ops", - tool="shell", - action_id="run-123", - policy_id="shell-command-risk", - policy_version="1.0.0", - risk="critical", - ), ) + +print(result.decision) +print(result.probabilities) +print(result.confidence) +``` + +OpenDecision returns model decisions and evidence; it does **not** ask Laya to generate a reason. Applications may add an explanation separately without confusing generated prose with decision-model output. + +## CLI + +Evaluate a decision pack against JSON state: + +```bash +opendecision evaluate decision_packs/security/tool_risk.yaml --state '{"tool": "send_email"}' ``` -Raw state is hashed for correlation and is not written by the built-in audit sinks. Applications remain responsible for authentication, authorization, durable review storage, encryption, retention, monitoring, calibration, and incident response. +Or render JSON for scripting: + +```bash +opendecision evaluate decision_packs/security/tool_risk.yaml --state state.json --json +``` + +Rich output is enabled automatically when `rich` is installed. + +## Decision packs + +```python +from opendecision import DecisionGuard, load_policy + +policy = load_policy("decision_packs/security/tool_risk.yaml") +result = DecisionGuard().decide(agent_state, policy.question) +``` + +The first pack includes a tool-risk contract with `allow`, `review`, and `block` outcomes. + +## Tool protection decorator + +```python +from opendecision import DecisionGuard, load_policy +from opendecision.tool_guard import protect_tool + +policy = load_policy("decision_packs/security/tool_risk.yaml") +guard = DecisionGuard() + +@protect_tool(guard, policy.question) +def send_email(to: str, body: str) -> None: + ... +``` + +The decorator fails closed: +- `allow` executes the tool +- `review` raises `ToolReviewRequiredError` +- anything else raises `ToolBlockedError` + +## LangGraph + +```python +from opendecision import DecisionGuard +from opendecision.integrations.langgraph import decision_node, route_by_decision + +graph.add_node("tool_guard", decision_node(DecisionGuard(), question)) +graph.add_conditional_edges( + "tool_guard", + route_by_decision(), + {"allow": "execute_tool", "review": "human_review", "block": "stop"}, +) +``` + +## FastAPI + +```bash +uvicorn app.main:app --reload +``` + +Then send `POST /v1/decide` with `state` and a typed `question`. + +## Architecture + +```text +Agent / application + | +DecisionGuard + contract + | +Provider adapter (Laya first) + | +Normalized DecisionResult + | +ALLOW | REVIEW | BLOCK +``` + +## Scope of v0.1 + +- Python SDK and Pydantic contracts +- lazy Laya provider +- choice, score, and noul normalization +- YAML decision packs +- LangGraph node and routing helpers +- FastAPI example +- unit tests that run without downloading model weights + +A dashboard, TypeScript SDK, additional frameworks, model-generated explanations, and evaluation tooling are intentionally deferred. + +## Calibration and safety + +Laya's documentation notes meaningful limitations: base checkpoints can be weak zero-shot on typed workflows, confidence may require domain calibration, and high-cardinality choices need special handling. Benchmark decision packs on representative data before automating consequential actions. Prefer human review when evidence or authorization is insufficient. ## Development ```bash pip install -e ".[dev]" -coverage run -m pytest -coverage report +pytest ruff check . -mypy opendecision ``` -See `docs/production-readiness.md` for operating guidance. +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. + +## License + +Apache License 2.0. Laya is a separate Apache-2.0 project and remains subject to its own license and notices. diff --git a/opendecision/__main__.py b/opendecision/__main__.py new file mode 100644 index 0000000..1a9369b --- /dev/null +++ b/opendecision/__main__.py @@ -0,0 +1,13 @@ +"""Package entrypoint for `python -m opendecision`.""" + +from __future__ import annotations + +from .cli import run + + +def main() -> None: + raise SystemExit(run()) + + +if __name__ == "__main__": + main() diff --git a/opendecision/cli.py b/opendecision/cli.py new file mode 100644 index 0000000..b784d92 --- /dev/null +++ b/opendecision/cli.py @@ -0,0 +1,111 @@ +"""Command-line interface for evaluating decision packs. + +Rich is an optional dependency. When available, output is rendered with tables. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Callable, Mapping + +from .guard import DecisionGuard +from .models import DecisionQuestion, DecisionResult +from .policies import load_policy + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="opendecision", description="Evaluate OpenDecision policies") + subparsers = parser.add_subparsers(dest="command", required=True) + + evaluate = subparsers.add_parser( + "evaluate", + help="Evaluate a decision-pack YAML policy against JSON state", + ) + evaluate.add_argument("policy", help="Path to a policy YAML file") + evaluate.add_argument( + "--state", + required=True, + help="JSON string or path to a JSON file containing agent state", + ) + evaluate.add_argument( + "--json", + action="store_true", + help="Render output as JSON (useful for scripting)", + ) + + return parser + + +def _load_state(value: str) -> Mapping[str, Any] | str: + path = Path(value) + if path.exists(): + return json.loads(path.read_text(encoding="utf-8")) + return json.loads(value) + + +def _render_plain(result: DecisionResult) -> str: + lines = [ + f"decision: {result.decision}", + f"provider: {result.provider}", + f"confidence: {result.confidence}", + ] + if result.probabilities: + lines.append("probabilities:") + for key, value in sorted(result.probabilities.items()): + lines.append(f" - {key}: {value}") + lines.append("limitations: Model confidence is uncalibrated; prefer review when uncertain.") + return "\n".join(lines) + + +def _render_rich(result: DecisionResult) -> str | None: + try: + from rich.console import Console + from rich.table import Table + except ImportError: + return None + + console = Console() + table = Table(title="OpenDecision") + table.add_column("Field") + table.add_column("Value") + table.add_row("decision", str(result.decision)) + table.add_row("provider", result.provider) + table.add_row("confidence", str(result.confidence)) + console.print(table) + + if result.probabilities: + probs = Table(title="Probabilities") + probs.add_column("label") + probs.add_column("p") + for label, prob in sorted(result.probabilities.items(), key=lambda item: item[0]): + probs.add_row(str(label), str(prob)) + console.print(probs) + + console.print( + "[dim]limitations: Model confidence is uncalibrated; prefer review when uncertain.[/dim]" + ) + return "" # indicate rich rendered + + +def run(argv: list[str] | None = None, *, guard_factory: Callable[[], DecisionGuard] = DecisionGuard) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command != "evaluate": + parser.error(f"Unknown command: {args.command}") + + policy = load_policy(args.policy) + state = _load_state(args.state) + guard = guard_factory() + result = guard.decide(state, policy.question) + + if args.json: + print(result.model_dump_json(indent=2)) + return 0 + + rendered = _render_rich(result) + if rendered is None: + print(_render_plain(result)) + return 0 diff --git a/opendecision/tool_guard.py b/opendecision/tool_guard.py new file mode 100644 index 0000000..93e705c --- /dev/null +++ b/opendecision/tool_guard.py @@ -0,0 +1,69 @@ +"""Framework-agnostic tool protection helpers. + +The decorator defined here is intentionally raw-Python and works with any agent +framework. It evaluates a decision contract before executing a tool and fails +closed for ambiguous outcomes. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, ParamSpec, TypeVar + +from .guard import DecisionGuard +from .models import DecisionQuestion, DecisionResult + +P = ParamSpec("P") +R = TypeVar("R") + + +@dataclass +class ToolBlockedError(RuntimeError): + result: DecisionResult + + +@dataclass +class ToolReviewRequiredError(RuntimeError): + result: DecisionResult + + +def protect_tool( + guard: DecisionGuard, + question: DecisionQuestion | Mapping[str, Any] | str, + *, + state_builder: Callable[[tuple[Any, ...], dict[str, Any]], Mapping[str, Any] | str] | None = None, + allow_value: str = "allow", + review_value: str = "review", + block_value: str = "block", +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Protect a tool call with a decision contract. + + The decorated function receives the same inputs, but is executed only when + the decision matches allow_value. Review and block raise dedicated errors. + """ + + contract = DecisionQuestion.from_input(question) + + def decorator(fn: Callable[P, R]) -> Callable[P, R]: + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + if state_builder is None: + state: Mapping[str, Any] | str = { + "tool": getattr(fn, "__name__", "tool"), + "args": args, + "kwargs": kwargs, + } + else: + state = state_builder(args, kwargs) + result = guard.decide(state, contract) + if result.decision == allow_value: + return fn(*args, **kwargs) + if result.decision == review_value: + raise ToolReviewRequiredError(result) + raise ToolBlockedError(result) + + wrapped.__name__ = getattr(fn, "__name__", "wrapped") + wrapped.__doc__ = fn.__doc__ + return wrapped + + return decorator diff --git a/pyproject.toml b/pyproject.toml index aa1fc00..dff3a3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,49 +4,34 @@ build-backend = "hatchling.build" [project] name = "opendecision" -version = "0.2.0" -description = "An auditable typed decision layer for AI agents" +version = "0.1.0" +description = "A typed decision layer for AI agents" readme = "README.md" requires-python = ">=3.10" license = { text = "Apache-2.0" } authors = [{ name = "OpenDecision contributors" }] -dependencies = ["pydantic>=2.7,<3", "PyYAML>=6.0,<7"] +dependencies = ["pydantic>=2.7,<3", "PyYAML>=6.0"] + +[project.scripts] +opendecision = "opendecision.cli:run" [project.optional-dependencies] laya = ["laya"] +rich = ["rich>=13.0"] langgraph = ["langgraph>=0.2"] fastapi = ["fastapi>=0.115", "uvicorn>=0.30"] -dev = [ - "coverage[toml]>=7.6", - "mypy>=1.10", - "pytest>=8.0", - "ruff>=0.6", - "types-PyYAML>=6.0", -] +dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.10"] [tool.hatch.build.targets.wheel] packages = ["opendecision"] [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-q --strict-markers" - -[tool.coverage.run] -branch = true -source = ["opendecision"] - -[tool.coverage.report] -fail_under = 85 -show_missing = true - -[tool.mypy] -python_version = "3.10" -strict = true -packages = ["opendecision"] +addopts = "-q" [tool.ruff] line-length = 100 target-version = "py310" [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "SIM"] +select = ["E", "F", "I", "UP"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..19b509b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,35 @@ +import json + +from opendecision.cli import run + + +class FakeProvider: + name = "fake" + + def __init__(self, answer): + self.answer = answer + + def predict(self, state, question): + return {"answer": self.answer, "raw": {"state": state}} + + +def test_cli_evaluate_outputs_json(capsys): + def factory(): + from opendecision import DecisionGuard + + return DecisionGuard(FakeProvider({"choice": "allow", "confidence": 0.9})) + + exit_code = run( + [ + "evaluate", + "decision_packs/security/tool_risk.yaml", + "--state", + json.dumps({"tool": "send_email"}), + "--json", + ], + guard_factory=factory, + ) + assert exit_code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["decision"] == "allow" + assert payload["provider"] == "fake" diff --git a/tests/test_tool_guard.py b/tests/test_tool_guard.py new file mode 100644 index 0000000..71900b3 --- /dev/null +++ b/tests/test_tool_guard.py @@ -0,0 +1,46 @@ +import pytest + +from opendecision import DecisionGuard +from opendecision.tool_guard import ToolBlockedError, ToolReviewRequiredError, protect_tool + + +class FakeProvider: + name = "fake" + + def __init__(self, answer): + self.answer = answer + + def predict(self, state, question): + return {"answer": self.answer, "raw": {"state": state}} + + +def test_protect_tool_allows_execution(): + guard = DecisionGuard(FakeProvider({"choice": "allow"})) + + @protect_tool(guard, {"type": "choice", "instructions": "?", "options": {"allow": "ok", "review": "r", "block": "b"}}) + def add(a, b): + return a + b + + assert add(1, 2) == 3 + + +def test_protect_tool_requests_review(): + guard = DecisionGuard(FakeProvider({"choice": "review"})) + + @protect_tool(guard, {"type": "choice", "instructions": "?", "options": {"allow": "ok", "review": "r", "block": "b"}}) + def add(a, b): + return a + b + + with pytest.raises(ToolReviewRequiredError): + add(1, 2) + + +def test_protect_tool_blocks(): + guard = DecisionGuard(FakeProvider({"choice": "block"})) + + @protect_tool(guard, {"type": "choice", "instructions": "?", "options": {"allow": "ok", "review": "r", "block": "b"}}) + def add(a, b): + return a + b + + with pytest.raises(ToolBlockedError): + add(1, 2) From 80754974c5e481ebe22e11dc7946a88eb3ebbf99 Mon Sep 17 00:00:00 2001 From: pateti Chandu Date: Fri, 25 Sep 2026 13:48:00 +0530 Subject: [PATCH 2/3] Fix lazy Laya import and export tool guard --- opendecision/__init__.py | 26 +---- opendecision/guard.py | 239 ++++++++------------------------------- 2 files changed, 51 insertions(+), 214 deletions(-) diff --git a/opendecision/__init__.py b/opendecision/__init__.py index 170a924..1a3224e 100644 --- a/opendecision/__init__.py +++ b/opendecision/__init__.py @@ -1,33 +1,15 @@ -"""OpenDecision: an auditable typed decision layer for AI agents.""" +"""OpenDecision: a typed decision layer for AI agents.""" -from .audit import AuditEvent, JsonlAuditSink, MemoryAuditSink -from .evaluation import EvaluationCase, EvaluationReport, evaluate from .guard import DecisionGuard -from .models import DecisionContext, DecisionQuestion, DecisionResult +from .models import DecisionQuestion, DecisionResult from .policies import DecisionPolicy, load_policy -from .providers import LayaProvider, ProviderChain, Rule, RuleProvider -from .review import InMemoryReviewStore, ReviewRequest, ReviewStatus __all__ = [ - "AuditEvent", - "DecisionContext", "DecisionGuard", - "DecisionPolicy", "DecisionQuestion", "DecisionResult", - "EvaluationCase", - "EvaluationReport", - "InMemoryReviewStore", - "JsonlAuditSink", - "LayaProvider", - "MemoryAuditSink", - "ProviderChain", - "ReviewRequest", - "ReviewStatus", - "Rule", - "RuleProvider", - "evaluate", + "DecisionPolicy", "load_policy", ] -__version__ = "0.2.0" +__version__ = "0.1.0" diff --git a/opendecision/guard.py b/opendecision/guard.py index 0aadba7..c825e0e 100644 --- a/opendecision/guard.py +++ b/opendecision/guard.py @@ -1,235 +1,98 @@ -"""DecisionGuard with strict validation, audit traces, and fail-safe behavior.""" +"""DecisionGuard and provider-output normalization.""" from __future__ import annotations -import hashlib -import json from collections.abc import Mapping -from datetime import datetime, timezone -from typing import Any, Literal -from uuid import uuid4 +from typing import Any -from .audit import AuditEvent, AuditSink -from .errors import ContractValidationError, ProviderError -from .models import DecisionContext, DecisionQuestion, DecisionResult, ProviderAttempt +from .models import DecisionQuestion, DecisionResult from .providers.base import DecisionProvider -from .providers.laya import LayaProvider - -FailureMode = Literal["raise", "review", "block"] class DecisionGuard: - """Evaluate typed contracts before an agent acts.""" + """Evaluate typed decision contracts before an agent takes an action.""" - def __init__( - self, - provider: DecisionProvider | None = None, - *, - model: str = "router", - audit_sink: AuditSink | None = None, - failure_mode: FailureMode = "raise", - max_state_bytes: int = 100_000, - ) -> None: - self.provider = provider or LayaProvider(model=model) - self.audit_sink = audit_sink - self.failure_mode = failure_mode - self.max_state_bytes = max_state_bytes + def __init__(self, provider: DecisionProvider | None = None, *, model: str = "router") -> None: + if provider is None: + # Laya is optional; import lazily so importing opendecision doesn't require it. + from .providers.laya import LayaProvider + + provider = LayaProvider(model=model) + self.provider = provider def decide( self, state: Mapping[str, Any] | str, question: DecisionQuestion | Mapping[str, Any] | str, - *, - context: DecisionContext | Mapping[str, Any] | None = None, ) -> DecisionResult: + """Evaluate a choice, score, or noul question and normalize its result.""" contract = DecisionQuestion.from_input(question) - decision_context = ( - context - if isinstance(context, DecisionContext) - else DecisionContext.model_validate(context or {}) - ) - context_hash = self._validate_and_hash_state(state) - try: - output = self.provider.predict(state, contract) - answer = output.get("answer", output) - raw_output = output.get("raw", output) - provider_name = str(output.get("provider", self.provider.name)) - attempts = [ - ProviderAttempt.model_validate(item) for item in output.get("attempts", []) - ] - result = self._normalize( - answer, - raw_output, - contract, - decision_context, - context_hash, - provider_name, - attempts, - ) - except Exception as exc: - if self.failure_mode == "raise": - if isinstance(exc, (ContractValidationError, ProviderError)): - raise - raise ProviderError(str(exc)) from exc - result = self._failure_result(contract, decision_context, context_hash, exc) - self._audit(result, decision_context) - return result - - def check( - self, - state: Mapping[str, Any] | str, - *, - decision: str, - context: DecisionContext | Mapping[str, Any] | None = None, - ) -> DecisionResult: - return self.decide( - state, - DecisionQuestion(type="noul", instructions=decision), - context=context, - ) + output = self.provider.predict(state, contract) + answer = output.get("answer", output) + raw_output = output.get("raw", output) + return self._normalize(answer, raw_output, contract) - def _validate_and_hash_state(self, state: Mapping[str, Any] | str) -> str: - if isinstance(state, str): - if not state.strip(): - raise ContractValidationError("state string cannot be empty") - encoded = state.encode() - elif isinstance(state, Mapping): - try: - encoded = json.dumps(state, sort_keys=True, separators=(",", ":")).encode() - except (TypeError, ValueError) as exc: - raise ContractValidationError("state must be JSON serializable") from exc - else: - raise ContractValidationError("state must be a string or mapping") - if len(encoded) > self.max_state_bytes: - raise ContractValidationError( - f"state exceeds configured limit of {self.max_state_bytes} bytes" - ) - return hashlib.sha256(encoded).hexdigest() + def check(self, state: Mapping[str, Any] | str, *, decision: str) -> DecisionResult: + """Convenience method for a boolean allow/block gate.""" + return self.decide(state, DecisionQuestion(type="noul", instructions=decision)) def _normalize( self, answer: Mapping[str, Any], raw_output: Any, question: DecisionQuestion, - context: DecisionContext, - context_hash: str, - provider_name: str, - attempts: list[ProviderAttempt], ) -> DecisionResult: if not isinstance(answer, Mapping): - raise ContractValidationError("provider answer must be a mapping") - common = { - "provider": provider_name, - "decision_id": str(uuid4()), - "created_at": datetime.now(timezone.utc), - "context_hash": context_hash, - "policy_id": context.policy_id, - "policy_version": context.policy_version, - "provider_attempts": attempts, - "raw_output": raw_output, - } + raise TypeError("decision provider must return a mapping for its answer") + if question.type == "choice": - probabilities = _probabilities(answer.get("probabilities") or answer.get("probs")) + probabilities = _float_mapping(answer.get("probabilities") or answer.get("probs")) decision = _first(answer, "choice", "label", "decision") - choice_options = question.options or ( - question.criteria if isinstance(question.criteria, dict) else {} - ) - labels = set(choice_options.keys()) - if decision not in labels: - raise ContractValidationError( - f"provider choice {decision!r} is not one of {sorted(labels)}" - ) - if probabilities and set(probabilities) != labels: - raise ContractValidationError("provider probabilities must match declared options") - confidence = _probability(_first(answer, "confidence")) + if decision is None: + raise ValueError("provider response did not contain a choice decision") + confidence = _as_probability(_first(answer, "confidence")) if confidence is None and probabilities: - confidence = probabilities[str(decision)] - requires_review = str(decision) == "review" + confidence = max(probabilities.values()) return DecisionResult( decision=str(decision), probabilities=probabilities, confidence=confidence, question_type="choice", - requires_review=requires_review, + provider=self.provider.name, metadata={"raw_answer": dict(answer)}, - **common, + raw_output=raw_output, ) + if question.type == "score": score = _first(answer, "score", "value", "decision") if score is None: - raise ContractValidationError("provider response did not contain a score") - confidence = _probability(_first(answer, "confidence")) - distribution = _probabilities( - answer.get("distribution") or answer.get("probabilities") - ) + raise ValueError("provider response did not contain a score decision") + distribution = _float_mapping(answer.get("distribution") or answer.get("probabilities")) + confidence = _as_probability(_first(answer, "confidence")) return DecisionResult( decision=float(score), probabilities=distribution, confidence=confidence, question_type="score", + provider=self.provider.name, metadata={"raw_answer": dict(answer)}, - **common, + raw_output=raw_output, ) - probability = _probability( + + probability = _as_probability( _first(answer, "noul", "probability", "confidence", "decision") ) if probability is None: - raise ContractValidationError("provider response did not contain a noul probability") + raise ValueError("provider response did not contain a noul probability") decision = probability >= question.threshold return DecisionResult( decision=decision, probabilities={"true": probability, "false": 1.0 - probability}, confidence=max(probability, 1.0 - probability), question_type="noul", + provider=self.provider.name, metadata={"threshold": question.threshold, "raw_answer": dict(answer)}, - **common, - ) - - def _failure_result( - self, - question: DecisionQuestion, - context: DecisionContext, - context_hash: str, - error: Exception, - ) -> DecisionResult: - review = self.failure_mode == "review" - if question.type == "choice": - choice_options = question.options or ( - question.criteria if isinstance(question.criteria, dict) else {} - ) - labels = list(choice_options.keys()) - preferred = "review" if review and "review" in labels else "block" - decision: str | bool = preferred if preferred in labels else labels[-1] - else: - decision = False - return DecisionResult( - decision=decision, - probabilities={}, - confidence=None, - question_type=question.type, - provider="failure_policy", - decision_id=str(uuid4()), - created_at=datetime.now(timezone.utc), - context_hash=context_hash, - policy_id=context.policy_id, - policy_version=context.policy_version, - requires_review=review, - metadata={ - "failure_mode": self.failure_mode, - "error": f"{type(error).__name__}: {error}", - }, - ) - - def _audit(self, result: DecisionResult, context: DecisionContext) -> None: - if self.audit_sink is None: - return - self.audit_sink.write( - AuditEvent.from_result( - result, - actor=context.actor, - tool=context.tool, - action_id=context.action_id, - ) + raw_output=raw_output, ) @@ -240,24 +103,16 @@ def _first(mapping: Mapping[str, Any], *keys: str) -> Any: return None -def _probability(value: Any) -> float | None: +def _as_probability(value: Any) -> float | None: if value is None: return None - probability = float(value) - if not 0.0 <= probability <= 1.0: - raise ContractValidationError(f"probability must be between 0 and 1, got {value}") - return probability + value = float(value) + if not 0.0 <= value <= 1.0: + raise ValueError(f"probability must be between 0 and 1, got {value}") + return value -def _probabilities(value: Any) -> dict[str, float]: - if value is None: - return {} +def _float_mapping(value: Any) -> dict[str, float]: if not isinstance(value, Mapping): - raise ContractValidationError("probabilities must be a mapping") - normalized: dict[str, float] = {} - for key, probability in value.items(): - validated = _probability(probability) - if validated is None: - raise ContractValidationError("probability cannot be null") - normalized[str(key)] = validated - return normalized + return {} + return {str(key): float(probability) for key, probability in value.items()} From acb0615781fff0a6a9943edc57f59b1ffefca9e0 Mon Sep 17 00:00:00 2001 From: pateti Chandu Date: Fri, 25 Sep 2026 13:48:09 +0530 Subject: [PATCH 3/3] Export tool guard helpers --- opendecision/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/opendecision/__init__.py b/opendecision/__init__.py index 1a3224e..3086d9a 100644 --- a/opendecision/__init__.py +++ b/opendecision/__init__.py @@ -3,13 +3,17 @@ from .guard import DecisionGuard from .models import DecisionQuestion, DecisionResult from .policies import DecisionPolicy, load_policy +from .tool_guard import ToolBlockedError, ToolReviewRequiredError, protect_tool __all__ = [ "DecisionGuard", "DecisionQuestion", "DecisionResult", "DecisionPolicy", + "ToolBlockedError", + "ToolReviewRequiredError", "load_policy", + "protect_tool", ] __version__ = "0.1.0"