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 +44 -0
- zolva/_db.py +17 -0
- zolva/_judge.py +15 -0
- zolva/audit.py +124 -0
- zolva/bridge/__init__.py +51 -0
- zolva/bridge/anthropic.py +91 -0
- zolva/bridge/fake.py +22 -0
- zolva/bridge/openai.py +83 -0
- zolva/bus.py +39 -0
- zolva/cli.py +141 -0
- zolva/config.py +96 -0
- zolva/evals.py +150 -0
- zolva/feedback.py +174 -0
- zolva/guardrails.py +152 -0
- zolva/handover.py +84 -0
- zolva/orchestrator.py +251 -0
- zolva/sessions.py +61 -0
- zolva/synthetics.py +121 -0
- zolva/tools.py +77 -0
- zolva-0.1.0.dist-info/METADATA +233 -0
- zolva-0.1.0.dist-info/RECORD +24 -0
- zolva-0.1.0.dist-info/WHEEL +4 -0
- zolva-0.1.0.dist-info/entry_points.txt +2 -0
- zolva-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
)
|
zolva/bridge/__init__.py
ADDED
|
@@ -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
|