agentcassette 0.1.0__py3-none-win_amd64.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.
- agentcassette-0.1.0.data/purelib/agentcassette/__init__.py +24 -0
- agentcassette-0.1.0.data/purelib/agentcassette/bin/agentcassette.exe +0 -0
- agentcassette-0.1.0.data/purelib/agentcassette/cassette_writer.py +169 -0
- agentcassette-0.1.0.data/purelib/agentcassette/cli.py +29 -0
- agentcassette-0.1.0.data/purelib/agentcassette/hashing.py +47 -0
- agentcassette-0.1.0.data/purelib/agentcassette/openai_hook.py +1187 -0
- agentcassette-0.1.0.data/purelib/agentcassette/openai_session.py +74 -0
- agentcassette-0.1.0.data/purelib/agentcassette/privacy.py +232 -0
- agentcassette-0.1.0.data/purelib/agentcassette/pytest.py +34 -0
- agentcassette-0.1.0.dist-info/METADATA +35 -0
- agentcassette-0.1.0.dist-info/RECORD +14 -0
- agentcassette-0.1.0.dist-info/WHEEL +5 -0
- agentcassette-0.1.0.dist-info/entry_points.txt +2 -0
- agentcassette-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Python runtime hooks for AgentCassette."""
|
|
2
|
+
|
|
3
|
+
from .openai_hook import (
|
|
4
|
+
OpenAIHookError,
|
|
5
|
+
OpenAIReplayDivergenceError,
|
|
6
|
+
OpenAIReplayError,
|
|
7
|
+
record_agent_step,
|
|
8
|
+
recording_openai,
|
|
9
|
+
recording_tool,
|
|
10
|
+
replaying_openai,
|
|
11
|
+
)
|
|
12
|
+
from .openai_session import openai_client, openai_session
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"OpenAIHookError",
|
|
16
|
+
"OpenAIReplayDivergenceError",
|
|
17
|
+
"OpenAIReplayError",
|
|
18
|
+
"openai_client",
|
|
19
|
+
"openai_session",
|
|
20
|
+
"record_agent_step",
|
|
21
|
+
"recording_openai",
|
|
22
|
+
"recording_tool",
|
|
23
|
+
"replaying_openai",
|
|
24
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Minimal JSONL cassette writer for Python runtime hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Mapping
|
|
9
|
+
|
|
10
|
+
from .hashing import hash_value
|
|
11
|
+
from .privacy import PrivacyMode, sanitize_event
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = "0.1"
|
|
14
|
+
|
|
15
|
+
ALLOWED_EVENTS = {
|
|
16
|
+
"trace.start",
|
|
17
|
+
"llm.call",
|
|
18
|
+
"llm.response",
|
|
19
|
+
"tool.call",
|
|
20
|
+
"tool.response",
|
|
21
|
+
"retrieval.call",
|
|
22
|
+
"retrieval.response",
|
|
23
|
+
"agent.step",
|
|
24
|
+
"error",
|
|
25
|
+
"trace.end",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CassetteWriter:
|
|
30
|
+
"""Write compact, flushed JSONL cassette events with a stable schema."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
path: str | os.PathLike[str],
|
|
35
|
+
*,
|
|
36
|
+
privacy: PrivacyMode = "safe",
|
|
37
|
+
sanitizer: Callable[[Any], Any] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.path = Path(path)
|
|
40
|
+
self.privacy = privacy
|
|
41
|
+
self.sanitizer = sanitizer
|
|
42
|
+
self._file = None
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> "CassetteWriter":
|
|
45
|
+
return self.open()
|
|
46
|
+
|
|
47
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
48
|
+
self.close()
|
|
49
|
+
|
|
50
|
+
def open(self) -> "CassetteWriter":
|
|
51
|
+
if self._file is not None:
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
fd = os.open(
|
|
56
|
+
self.path,
|
|
57
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
58
|
+
0o600,
|
|
59
|
+
)
|
|
60
|
+
self._file = os.fdopen(fd, "w", encoding="utf-8", newline="\n")
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def write_event(self, fields: Mapping[str, Any]) -> dict[str, Any]:
|
|
64
|
+
if self._file is None:
|
|
65
|
+
raise RuntimeError("cassette writer is not open")
|
|
66
|
+
|
|
67
|
+
event = sanitize_event(fields, mode=self.privacy, sanitizer=self.sanitizer)
|
|
68
|
+
event.setdefault("schema_version", SCHEMA_VERSION)
|
|
69
|
+
_refresh_hash_fields(event, privacy=self.privacy)
|
|
70
|
+
_validate_event(event)
|
|
71
|
+
|
|
72
|
+
json.dump(
|
|
73
|
+
event,
|
|
74
|
+
self._file,
|
|
75
|
+
sort_keys=True,
|
|
76
|
+
separators=(",", ":"),
|
|
77
|
+
ensure_ascii=False,
|
|
78
|
+
allow_nan=False,
|
|
79
|
+
)
|
|
80
|
+
self._file.write("\n")
|
|
81
|
+
self._file.flush()
|
|
82
|
+
return event
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
if self._file is None:
|
|
86
|
+
return
|
|
87
|
+
file_obj = self._file
|
|
88
|
+
self._file = None
|
|
89
|
+
file_obj.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _validate_event(event: Mapping[str, Any]) -> None:
|
|
93
|
+
version = event.get("schema_version")
|
|
94
|
+
if version != SCHEMA_VERSION:
|
|
95
|
+
raise ValueError(f"unsupported schema_version {version!r}")
|
|
96
|
+
|
|
97
|
+
event_type = event.get("event")
|
|
98
|
+
if not isinstance(event_type, str) or not event_type:
|
|
99
|
+
raise ValueError("event must be a non-empty string")
|
|
100
|
+
if event_type not in ALLOWED_EVENTS:
|
|
101
|
+
raise ValueError(f"unknown event type {event_type!r}")
|
|
102
|
+
|
|
103
|
+
if event_type == "trace.start":
|
|
104
|
+
_require_string(event, "trace_id")
|
|
105
|
+
_require_string(event, "name")
|
|
106
|
+
elif event_type == "llm.call":
|
|
107
|
+
_require_string(event, "span_id")
|
|
108
|
+
_require_string(event, "provider")
|
|
109
|
+
_require_string(event, "model")
|
|
110
|
+
_require_string(event, "input_hash")
|
|
111
|
+
elif event_type == "llm.response":
|
|
112
|
+
_require_string(event, "span_id")
|
|
113
|
+
_require_any(event, ("output", "output_hash", "error"))
|
|
114
|
+
elif event_type == "tool.call":
|
|
115
|
+
_require_string(event, "span_id")
|
|
116
|
+
_require_string(event, "name")
|
|
117
|
+
elif event_type == "tool.response":
|
|
118
|
+
_require_string(event, "span_id")
|
|
119
|
+
_require_any(event, ("output", "error"))
|
|
120
|
+
elif event_type == "retrieval.call":
|
|
121
|
+
_require_string(event, "span_id")
|
|
122
|
+
_require_any(event, ("query", "input_hash"))
|
|
123
|
+
elif event_type == "retrieval.response":
|
|
124
|
+
_require_string(event, "span_id")
|
|
125
|
+
_require_any(event, ("documents", "output_hash"))
|
|
126
|
+
elif event_type == "agent.step":
|
|
127
|
+
_require_string(event, "name")
|
|
128
|
+
elif event_type == "error":
|
|
129
|
+
_require_string(event, "message")
|
|
130
|
+
elif event_type == "trace.end":
|
|
131
|
+
_require_string(event, "trace_id")
|
|
132
|
+
_require_string(event, "status")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _refresh_hash_fields(event: dict[str, Any], *, privacy: PrivacyMode) -> None:
|
|
136
|
+
if privacy == "hide_all":
|
|
137
|
+
for field in ("output_hash",):
|
|
138
|
+
event.pop(field, None)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if "input" in event:
|
|
142
|
+
event["input_hash"] = hash_value(event["input"])
|
|
143
|
+
if "output" in event:
|
|
144
|
+
event["output_hash"] = hash_value(event["output"])
|
|
145
|
+
if "documents" in event:
|
|
146
|
+
event["output_hash"] = hash_value(event["documents"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _require_string(event: Mapping[str, Any], field: str) -> None:
|
|
150
|
+
value = event.get(field)
|
|
151
|
+
if not isinstance(value, str) or not value:
|
|
152
|
+
raise ValueError(f"{field} must be a non-empty string")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _require_any(event: Mapping[str, Any], fields: tuple[str, ...]) -> None:
|
|
156
|
+
for field in fields:
|
|
157
|
+
if field not in event:
|
|
158
|
+
continue
|
|
159
|
+
value = event[field]
|
|
160
|
+
if field == "documents":
|
|
161
|
+
if isinstance(value, list):
|
|
162
|
+
return
|
|
163
|
+
raise ValueError("documents must be an array")
|
|
164
|
+
if value is None:
|
|
165
|
+
raise ValueError(f"{field} must not be null")
|
|
166
|
+
if isinstance(value, str) and not value:
|
|
167
|
+
raise ValueError(f"{field} must not be empty")
|
|
168
|
+
return
|
|
169
|
+
raise ValueError(f"missing one of: {fields}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Launcher for the AgentCassette Go CLI bundled in the Python package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def binary_path() -> os.PathLike[str]:
|
|
12
|
+
name = "agentcassette.exe" if os.name == "nt" else "agentcassette"
|
|
13
|
+
return files("agentcassette").joinpath("bin", name)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
binary = binary_path()
|
|
18
|
+
if not binary.is_file():
|
|
19
|
+
raise SystemExit(
|
|
20
|
+
"AgentCassette CLI binary is missing from this installation. "
|
|
21
|
+
"Install an official platform wheel instead of a source distribution."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
result = subprocess.run([os.fspath(binary), *sys.argv[1:]], check=False)
|
|
25
|
+
raise SystemExit(result.returncode)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Canonical JSON hashing compatible with the Go cassette package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
HASH_PREFIX = "sha256:"
|
|
10
|
+
|
|
11
|
+
_GO_JSON_ESCAPES = {
|
|
12
|
+
ord("<"): "\\u003c",
|
|
13
|
+
ord(">"): "\\u003e",
|
|
14
|
+
ord("&"): "\\u0026",
|
|
15
|
+
ord("\u2028"): "\\u2028",
|
|
16
|
+
ord("\u2029"): "\\u2029",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def canonical_json(value: Any) -> str:
|
|
21
|
+
"""Return compact, key-sorted JSON for a JSON-serializable value."""
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
encoded = json.dumps(
|
|
25
|
+
value,
|
|
26
|
+
sort_keys=True,
|
|
27
|
+
separators=(",", ":"),
|
|
28
|
+
ensure_ascii=False,
|
|
29
|
+
allow_nan=False,
|
|
30
|
+
)
|
|
31
|
+
return encoded.translate(_GO_JSON_ESCAPES)
|
|
32
|
+
except (TypeError, ValueError) as exc:
|
|
33
|
+
raise ValueError(f"canonicalize JSON for hash: {exc}") from exc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hash_value(value: Any) -> str:
|
|
37
|
+
canonical = canonical_json(value)
|
|
38
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
39
|
+
return f"{HASH_PREFIX}{digest}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def hash_json(raw: str | bytes | bytearray) -> str:
|
|
43
|
+
try:
|
|
44
|
+
value = json.loads(raw)
|
|
45
|
+
except json.JSONDecodeError as exc:
|
|
46
|
+
raise ValueError(f"decode JSON for hash: {exc}") from exc
|
|
47
|
+
return hash_value(value)
|