zolva 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.
zolva/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Zolva: self-hosted agent platform for banks and fintechs."""
2
+
3
+ from zolva.audit import AuditLog, Scorecard, scorecard
4
+ from zolva.bus import Bus, Step, Verdict
5
+ from zolva.config import AgentConfig, ConfigError, load_agents
6
+ from zolva.evals import EvalReport, EvalRunner, load_cohorts
7
+ from zolva.feedback import Failure, FeedbackQueue
8
+ from zolva.guardrails import Guardrails
9
+ from zolva.handover import HandoverBackend, LogBackend, Ticket, WebhookBackend
10
+ from zolva.orchestrator import BLOCKED_MESSAGE, AgentApp
11
+ from zolva.synthetics import SyntheticResult, SyntheticRunner, load_synthetics
12
+ from zolva.tools import ToolRegistry, default_registry, tool
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "BLOCKED_MESSAGE",
18
+ "AgentApp",
19
+ "AgentConfig",
20
+ "AuditLog",
21
+ "Bus",
22
+ "ConfigError",
23
+ "EvalReport",
24
+ "EvalRunner",
25
+ "Failure",
26
+ "FeedbackQueue",
27
+ "Guardrails",
28
+ "HandoverBackend",
29
+ "LogBackend",
30
+ "Scorecard",
31
+ "Step",
32
+ "SyntheticResult",
33
+ "SyntheticRunner",
34
+ "Ticket",
35
+ "ToolRegistry",
36
+ "Verdict",
37
+ "WebhookBackend",
38
+ "default_registry",
39
+ "load_agents",
40
+ "load_cohorts",
41
+ "load_synthetics",
42
+ "scorecard",
43
+ "tool",
44
+ ]
zolva/_db.py ADDED
@@ -0,0 +1,17 @@
1
+ """Shared sqlite helper: sqlite3's own context manager commits but never closes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+
9
+
10
+ @contextmanager
11
+ def sqlite_conn(path: str) -> Iterator[sqlite3.Connection]:
12
+ conn = sqlite3.connect(path)
13
+ try:
14
+ with conn:
15
+ yield conn
16
+ finally:
17
+ conn.close()
zolva/_judge.py ADDED
@@ -0,0 +1,15 @@
1
+ """Shared binary LLM-judge call. Fail-closed: any answer that isn't PASS is a fail."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zolva.bridge import LLMAdapter, Message
6
+
7
+
8
+ async def judge_passes(adapter: LLMAdapter, *, model: str, system: str, content: str) -> bool:
9
+ resp = await adapter.complete(
10
+ model=model,
11
+ system=system,
12
+ messages=[Message(role="user", content=content)],
13
+ tools=[],
14
+ )
15
+ return resp.text.strip().upper().startswith("PASS")
zolva/audit.py ADDED
@@ -0,0 +1,124 @@
1
+ """Audit plugin: hash-chained, append-only log of every bus step + the SARR scorecard.
2
+
3
+ Each row's hash covers the previous row's hash, so silent edits and deletions
4
+ are detectable (`AuditLog.verify()`). SQLite by default; point the path at
5
+ WORM-backed storage for regulators.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import sqlite3
13
+ from contextlib import AbstractContextManager
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ from pydantic import BaseModel
18
+
19
+ from zolva._db import sqlite_conn
20
+ from zolva.bus import Step, Verdict
21
+ from zolva.orchestrator import AgentApp
22
+
23
+ _GENESIS = "genesis"
24
+
25
+
26
+ class AuditLog:
27
+ def __init__(self, path: str | Path) -> None:
28
+ self._path = str(path)
29
+ self._attached = False
30
+ with self._conn() as conn:
31
+ conn.execute(
32
+ "CREATE TABLE IF NOT EXISTS audit ("
33
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, "
34
+ "session_id TEXT NOT NULL, agent TEXT NOT NULL, type TEXT NOT NULL, "
35
+ "data TEXT NOT NULL, prev_hash TEXT NOT NULL, hash TEXT NOT NULL)"
36
+ )
37
+
38
+ def _conn(self) -> AbstractContextManager[sqlite3.Connection]:
39
+ return sqlite_conn(self._path)
40
+
41
+ def attach(self, app: AgentApp) -> None:
42
+ if self._attached:
43
+ return # idempotent: a second attach must not double-log every step
44
+ self._attached = True
45
+ app.bus.on(self._observe)
46
+
47
+ async def _observe(self, step: Step) -> Verdict | None:
48
+ self.append(step)
49
+ return None
50
+
51
+ def append(self, step: Step) -> None:
52
+ ts = datetime.now(timezone.utc).isoformat()
53
+ payload = json.dumps(step.data, sort_keys=True, default=str)
54
+ with self._conn() as conn:
55
+ row = conn.execute("SELECT hash FROM audit ORDER BY id DESC LIMIT 1").fetchone()
56
+ prev = row[0] if row else _GENESIS
57
+ digest = hashlib.sha256(
58
+ f"{prev}|{ts}|{step.session_id}|{step.agent}|{step.type}|{payload}".encode()
59
+ ).hexdigest()
60
+ conn.execute(
61
+ "INSERT INTO audit (ts, session_id, agent, type, data, prev_hash, hash) "
62
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
63
+ (ts, step.session_id, step.agent, step.type, payload, prev, digest),
64
+ )
65
+
66
+ def verify(self) -> bool:
67
+ """Recompute the chain; any edited, deleted, or reordered row breaks it."""
68
+ prev = _GENESIS
69
+ with self._conn() as conn:
70
+ rows = conn.execute(
71
+ "SELECT ts, session_id, agent, type, data, prev_hash, hash FROM audit ORDER BY id"
72
+ ).fetchall()
73
+ for ts, session_id, agent, step_type, data, prev_hash, digest in rows:
74
+ if prev_hash != prev:
75
+ return False
76
+ expected = hashlib.sha256(
77
+ f"{prev}|{ts}|{session_id}|{agent}|{step_type}|{data}".encode()
78
+ ).hexdigest()
79
+ if digest != expected:
80
+ return False
81
+ prev = digest
82
+ return True
83
+
84
+ def step_types_by_session(self) -> dict[str, set[str]]:
85
+ with self._conn() as conn:
86
+ rows = conn.execute("SELECT session_id, type FROM audit").fetchall()
87
+ by_session: dict[str, set[str]] = {}
88
+ for session_id, step_type in rows:
89
+ by_session.setdefault(session_id, set()).add(step_type)
90
+ return by_session
91
+
92
+
93
+ class Scorecard(BaseModel):
94
+ sessions: int
95
+ resolved: int
96
+ escalated: int
97
+ sarr: float # Safe Automated Resolution Rate
98
+ containment: float
99
+
100
+ def summary(self) -> str:
101
+ return (
102
+ f"sessions={self.sessions} resolved={self.resolved} escalated={self.escalated}\n"
103
+ f"SARR={self.sarr:.1%} containment={self.containment:.1%}"
104
+ )
105
+
106
+
107
+ def scorecard(audit: AuditLog) -> Scorecard:
108
+ """SARR v1: session got a response and never escalated.
109
+
110
+ ponytail: no re-contact window yet (needs customer identity across
111
+ sessions); add when a bank wires customer refs into session ids.
112
+ """
113
+ by_session = audit.step_types_by_session()
114
+ sessions = [types for sid, types in by_session.items() if "user_msg" in types]
115
+ total = len(sessions)
116
+ escalated = sum(1 for types in sessions if "handover" in types)
117
+ resolved = sum(1 for types in sessions if "response" in types and "handover" not in types)
118
+ return Scorecard(
119
+ sessions=total,
120
+ resolved=resolved,
121
+ escalated=escalated,
122
+ sarr=resolved / total if total else 0.0,
123
+ containment=1 - (escalated / total) if total else 0.0,
124
+ )
@@ -0,0 +1,51 @@
1
+ """Vendor-neutral LLM bridge: one Protocol, one adapter per provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Literal, Protocol
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from zolva.tools import ToolSpec
10
+
11
+
12
+ class BridgeError(Exception):
13
+ """LLM provider or adapter failure."""
14
+
15
+
16
+ class ToolCall(BaseModel):
17
+ id: str
18
+ name: str
19
+ args: dict[str, Any]
20
+
21
+
22
+ class Message(BaseModel):
23
+ role: Literal["system", "user", "assistant", "tool"]
24
+ content: str
25
+ tool_call_id: str | None = None
26
+ tool_calls: list[ToolCall] = []
27
+
28
+
29
+ class LLMResponse(BaseModel):
30
+ text: str = ""
31
+ tool_calls: list[ToolCall] = []
32
+
33
+
34
+ class LLMAdapter(Protocol):
35
+ async def complete(
36
+ self, *, model: str, system: str, messages: list[Message], tools: list[ToolSpec]
37
+ ) -> LLMResponse: ...
38
+
39
+
40
+ _ADAPTERS: dict[str, Callable[[], LLMAdapter]] = {}
41
+
42
+
43
+ def register_adapter(provider: str, factory: Callable[[], LLMAdapter]) -> None:
44
+ _ADAPTERS[provider] = factory
45
+
46
+
47
+ def get_adapter(provider: str) -> LLMAdapter:
48
+ try:
49
+ return _ADAPTERS[provider]()
50
+ except KeyError:
51
+ raise BridgeError(f"unknown provider {provider!r}") from None
@@ -0,0 +1,91 @@
1
+ """Anthropic messages adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from zolva.bridge import BridgeError, LLMResponse, Message, ToolCall, register_adapter
11
+ from zolva.tools import ToolSpec
12
+
13
+
14
+ class AnthropicAdapter:
15
+ def __init__(
16
+ self,
17
+ api_key: str | None = None,
18
+ base_url: str = "https://api.anthropic.com",
19
+ transport: httpx.AsyncBaseTransport | None = None,
20
+ max_tokens: int = 4096,
21
+ ) -> None:
22
+ self._max_tokens = max_tokens
23
+ key = api_key or os.environ.get("ANTHROPIC_API_KEY")
24
+ if not key:
25
+ raise BridgeError("ANTHROPIC_API_KEY not set and no api_key given")
26
+ self._client = httpx.AsyncClient(
27
+ base_url=base_url,
28
+ headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
29
+ transport=transport,
30
+ timeout=60.0,
31
+ )
32
+
33
+ def _wire_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
34
+ wire: list[dict[str, Any]] = []
35
+ for m in messages:
36
+ if m.role == "tool":
37
+ wire.append(
38
+ {
39
+ "role": "user",
40
+ "content": [
41
+ {
42
+ "type": "tool_result",
43
+ "tool_use_id": m.tool_call_id,
44
+ "content": m.content,
45
+ }
46
+ ],
47
+ }
48
+ )
49
+ elif m.role == "assistant" and m.tool_calls:
50
+ blocks: list[dict[str, Any]] = []
51
+ if m.content:
52
+ blocks.append({"type": "text", "text": m.content})
53
+ blocks += [
54
+ {"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.args}
55
+ for tc in m.tool_calls
56
+ ]
57
+ wire.append({"role": "assistant", "content": blocks})
58
+ else:
59
+ wire.append({"role": m.role, "content": m.content})
60
+ return wire
61
+
62
+ async def complete(
63
+ self, *, model: str, system: str, messages: list[Message], tools: list[ToolSpec]
64
+ ) -> LLMResponse:
65
+ body: dict[str, Any] = {
66
+ "model": model,
67
+ "max_tokens": self._max_tokens,
68
+ "system": system,
69
+ "messages": self._wire_messages(messages),
70
+ }
71
+ if tools:
72
+ body["tools"] = [
73
+ {"name": t.name, "description": t.description, "input_schema": t.parameters}
74
+ for t in tools
75
+ ]
76
+ try:
77
+ r = await self._client.post("/v1/messages", json=body)
78
+ r.raise_for_status()
79
+ except httpx.HTTPError as e:
80
+ raise BridgeError(f"anthropic: {e}") from e
81
+ text = ""
82
+ calls: list[ToolCall] = []
83
+ for block in r.json()["content"]:
84
+ if block["type"] == "text":
85
+ text += block["text"]
86
+ elif block["type"] == "tool_use":
87
+ calls.append(ToolCall(id=block["id"], name=block["name"], args=block["input"]))
88
+ return LLMResponse(text=text, tool_calls=calls)
89
+
90
+
91
+ register_adapter("anthropic", AnthropicAdapter)
zolva/bridge/fake.py ADDED
@@ -0,0 +1,22 @@
1
+ """Scripted adapter for tests and offline development. Shipped on purpose."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from zolva.bridge import BridgeError, LLMResponse, Message
8
+ from zolva.tools import ToolSpec
9
+
10
+
11
+ class FakeAdapter:
12
+ def __init__(self, script: list[LLMResponse]) -> None:
13
+ self._script = list(script)
14
+ self.calls: list[dict[str, Any]] = []
15
+
16
+ async def complete(
17
+ self, *, model: str, system: str, messages: list[Message], tools: list[ToolSpec]
18
+ ) -> LLMResponse:
19
+ self.calls.append({"model": model, "system": system, "messages": messages, "tools": tools})
20
+ if not self._script:
21
+ raise BridgeError("FakeAdapter script exhausted")
22
+ return self._script.pop(0)
zolva/bridge/openai.py ADDED
@@ -0,0 +1,83 @@
1
+ """OpenAI chat-completions adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from zolva.bridge import BridgeError, LLMResponse, Message, ToolCall, register_adapter
12
+ from zolva.tools import ToolSpec
13
+
14
+
15
+ class OpenAIAdapter:
16
+ def __init__(
17
+ self,
18
+ api_key: str | None = None,
19
+ base_url: str = "https://api.openai.com/v1",
20
+ transport: httpx.AsyncBaseTransport | None = None,
21
+ ) -> None:
22
+ key = api_key or os.environ.get("OPENAI_API_KEY")
23
+ if not key:
24
+ raise BridgeError("OPENAI_API_KEY not set and no api_key given")
25
+ self._client = httpx.AsyncClient(
26
+ base_url=base_url,
27
+ headers={"Authorization": f"Bearer {key}"},
28
+ transport=transport,
29
+ timeout=60.0,
30
+ )
31
+
32
+ def _wire_messages(self, system: str, messages: list[Message]) -> list[dict[str, Any]]:
33
+ wire: list[dict[str, Any]] = [{"role": "system", "content": system}]
34
+ for m in messages:
35
+ item: dict[str, Any] = {"role": m.role, "content": m.content}
36
+ if m.role == "tool":
37
+ item["tool_call_id"] = m.tool_call_id
38
+ if m.tool_calls:
39
+ item["tool_calls"] = [
40
+ {
41
+ "id": tc.id,
42
+ "type": "function",
43
+ "function": {"name": tc.name, "arguments": json.dumps(tc.args)},
44
+ }
45
+ for tc in m.tool_calls
46
+ ]
47
+ wire.append(item)
48
+ return wire
49
+
50
+ async def complete(
51
+ self, *, model: str, system: str, messages: list[Message], tools: list[ToolSpec]
52
+ ) -> LLMResponse:
53
+ body: dict[str, Any] = {"model": model, "messages": self._wire_messages(system, messages)}
54
+ if tools:
55
+ body["tools"] = [
56
+ {
57
+ "type": "function",
58
+ "function": {
59
+ "name": t.name,
60
+ "description": t.description,
61
+ "parameters": t.parameters,
62
+ },
63
+ }
64
+ for t in tools
65
+ ]
66
+ try:
67
+ r = await self._client.post("/chat/completions", json=body)
68
+ r.raise_for_status()
69
+ except httpx.HTTPError as e:
70
+ raise BridgeError(f"openai: {e}") from e
71
+ msg = r.json()["choices"][0]["message"]
72
+ calls = [
73
+ ToolCall(
74
+ id=tc["id"],
75
+ name=tc["function"]["name"],
76
+ args=json.loads(tc["function"]["arguments"]),
77
+ )
78
+ for tc in (msg.get("tool_calls") or [])
79
+ ]
80
+ return LLMResponse(text=msg.get("content") or "", tool_calls=calls)
81
+
82
+
83
+ register_adapter("openai", OpenAIAdapter)
zolva/bus.py ADDED
@@ -0,0 +1,39 @@
1
+ """Middleware bus: every orchestrator step flows through here. Plugins attach as hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Awaitable, Callable, Literal
6
+
7
+ from pydantic import BaseModel
8
+
9
+ StepType = Literal["user_msg", "model_call", "tool_call", "response", "handover", "feedback"]
10
+
11
+
12
+ class Step(BaseModel):
13
+ type: StepType
14
+ session_id: str
15
+ agent: str
16
+ data: dict[str, Any]
17
+
18
+
19
+ class Verdict(BaseModel):
20
+ allow: bool = True
21
+ reason: str | None = None
22
+
23
+
24
+ Hook = Callable[[Step], Awaitable[Verdict | None]]
25
+
26
+
27
+ class Bus:
28
+ def __init__(self) -> None:
29
+ self._hooks: list[Hook] = []
30
+
31
+ def on(self, hook: Hook) -> None:
32
+ self._hooks.append(hook)
33
+
34
+ async def emit(self, step: Step) -> Verdict:
35
+ for hook in self._hooks:
36
+ verdict = await hook(step)
37
+ if verdict is not None and not verdict.allow:
38
+ return verdict
39
+ return Verdict()
zolva/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ """zolva CLI: validate, eval, scorecard, triage, export-dataset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import importlib
8
+ import json
9
+ import sys
10
+ from typing import Any
11
+
12
+ from zolva.config import ConfigError, load_agents
13
+ from zolva.orchestrator import AgentApp
14
+
15
+
16
+ def _load_app(spec: str) -> AgentApp:
17
+ """Import 'module.path:attr' and return the bank's AgentApp instance."""
18
+ module_name, _, attr = spec.partition(":")
19
+ if not module_name or not attr:
20
+ raise ConfigError(f"--app must be 'module.path:attr', got {spec!r}")
21
+ module = importlib.import_module(module_name)
22
+ app = getattr(module, attr, None)
23
+ if not isinstance(app, AgentApp):
24
+ raise ConfigError(f"{spec} is not an AgentApp instance")
25
+ return app
26
+
27
+
28
+ def _cmd_validate(args: argparse.Namespace) -> int:
29
+ agents = load_agents(args.config_dir)
30
+ for cfg in agents.values():
31
+ print(
32
+ f"{cfg.name} {cfg.model.provider}/{cfg.model.name} "
33
+ f"tools={len(cfg.tools)} handoffs={cfg.handoffs}"
34
+ )
35
+ print(f"OK: {len(agents)} agent(s) valid")
36
+ return 0
37
+
38
+
39
+ def _cmd_eval(args: argparse.Namespace) -> int:
40
+ from zolva.bridge import get_adapter
41
+ from zolva.evals import EvalRunner
42
+
43
+ app = _load_app(args.app)
44
+ judge = get_adapter(args.judge_provider) if args.judge_provider else None
45
+ runner = EvalRunner(app, judge=judge, judge_model=args.judge_model)
46
+ report = asyncio.run(runner.run(args.evals_dir))
47
+ print(report.summary())
48
+ if args.out:
49
+ with open(args.out, "w") as f:
50
+ json.dump(report.model_dump(), f, indent=2)
51
+ print(f"wrote {args.out}")
52
+ if args.gate and not report.gate_passed:
53
+ return 1 # the CI story: any CI can run a command that exits 1
54
+ return 0
55
+
56
+
57
+ def _cmd_scorecard(args: argparse.Namespace) -> int:
58
+ from zolva.audit import AuditLog, scorecard
59
+
60
+ log = AuditLog(args.audit_db)
61
+ if not log.verify():
62
+ print("AUDIT CHAIN BROKEN: log has been tampered with", file=sys.stderr)
63
+ return 1
64
+ print(scorecard(log).summary())
65
+ return 0
66
+
67
+
68
+ def _cmd_triage(args: argparse.Namespace) -> int:
69
+ from zolva.feedback import FeedbackQueue
70
+
71
+ q = FeedbackQueue(args.failures_db)
72
+ if args.accept is not None:
73
+ if not args.cohort or not args.expect:
74
+ print("--accept requires --cohort and --expect", file=sys.stderr)
75
+ return 1
76
+ q.accept(args.accept, args.cohort, expect=args.expect)
77
+ print(f"failure {args.accept} promoted to {args.cohort}")
78
+ return 0
79
+ if args.reject is not None:
80
+ q.reject(args.reject)
81
+ print(f"failure {args.reject} rejected")
82
+ return 0
83
+ pending = q.pending()
84
+ for f in pending:
85
+ last_user = next((m.content for m in reversed(f.transcript) if m.role == "user"), "")
86
+ print(f"#{f.id} [{f.kind}] agent={f.agent} note={f.note!r} last_user={last_user!r}")
87
+ print(f"{len(pending)} pending failure(s)")
88
+ return 0
89
+
90
+
91
+ def _cmd_export_dataset(args: argparse.Namespace) -> int:
92
+ from zolva.feedback import FeedbackQueue
93
+
94
+ n = FeedbackQueue(args.failures_db).export_dataset(args.out)
95
+ print(f"wrote {n} accepted failure(s) to {args.out}")
96
+ return 0
97
+
98
+
99
+ def main(argv: list[str] | None = None) -> int:
100
+ parser = argparse.ArgumentParser(prog="zolva")
101
+ sub = parser.add_subparsers(dest="command", required=True)
102
+
103
+ p_validate = sub.add_parser("validate", help="validate an agent config directory")
104
+ p_validate.add_argument("config_dir")
105
+
106
+ p_eval = sub.add_parser("eval", help="run eval cohorts against your app")
107
+ p_eval.add_argument("evals_dir")
108
+ p_eval.add_argument("--app", required=True, help="import path to your AgentApp: module:attr")
109
+ p_eval.add_argument("--gate", action="store_true", help="exit 1 if the worst cohort fails")
110
+ p_eval.add_argument("--judge-provider", default="", help="bridge provider for the judge")
111
+ p_eval.add_argument("--judge-model", default="", help="model name for the judge")
112
+ p_eval.add_argument("--out", default="", help="write full report JSON to this path")
113
+
114
+ p_score = sub.add_parser("scorecard", help="verify the audit chain and print SARR")
115
+ p_score.add_argument("audit_db")
116
+
117
+ p_triage = sub.add_parser("triage", help="list/promote/reject pending failures")
118
+ p_triage.add_argument("failures_db")
119
+ p_triage.add_argument("--accept", type=int, default=None, metavar="ID")
120
+ p_triage.add_argument("--cohort", default="", help="eval cohort file to promote into")
121
+ p_triage.add_argument("--expect", default="", help="expected behavior for the judge")
122
+ p_triage.add_argument("--reject", type=int, default=None, metavar="ID")
123
+
124
+ p_export = sub.add_parser("export-dataset", help="accepted failures as fine-tuning JSONL")
125
+ p_export.add_argument("failures_db")
126
+ p_export.add_argument("out")
127
+
128
+ args = parser.parse_args(argv)
129
+ commands: dict[str, Any] = {
130
+ "validate": _cmd_validate,
131
+ "eval": _cmd_eval,
132
+ "scorecard": _cmd_scorecard,
133
+ "triage": _cmd_triage,
134
+ "export-dataset": _cmd_export_dataset,
135
+ }
136
+ try:
137
+ result: int = commands[args.command](args)
138
+ return result
139
+ except ConfigError as e:
140
+ print(f"config error: {e}", file=sys.stderr)
141
+ return 1