agenttwin 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.
- agenttwin/__init__.py +73 -0
- agenttwin/__main__.py +14 -0
- agenttwin/__pycache__/__init__.cpython-312.pyc +0 -0
- agenttwin/__pycache__/__main__.cpython-312.pyc +0 -0
- agenttwin/__pycache__/asserts.cpython-312.pyc +0 -0
- agenttwin/__pycache__/cli.cpython-312.pyc +0 -0
- agenttwin/__pycache__/diff.cpython-312.pyc +0 -0
- agenttwin/__pycache__/drift.cpython-312.pyc +0 -0
- agenttwin/__pycache__/env.cpython-312.pyc +0 -0
- agenttwin/__pycache__/fingerprint.cpython-312.pyc +0 -0
- agenttwin/__pycache__/gallery.cpython-312.pyc +0 -0
- agenttwin/__pycache__/proxy.cpython-312.pyc +0 -0
- agenttwin/__pycache__/redact.cpython-312.pyc +0 -0
- agenttwin/__pycache__/report.cpython-312.pyc +0 -0
- agenttwin/__pycache__/trace.cpython-312.pyc +0 -0
- agenttwin/adapters/__init__.py +22 -0
- agenttwin/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
- agenttwin/adapters/__pycache__/base.cpython-312.pyc +0 -0
- agenttwin/adapters/__pycache__/openai_compatible.cpython-312.pyc +0 -0
- agenttwin/adapters/base.py +96 -0
- agenttwin/adapters/openai_compatible.py +329 -0
- agenttwin/asserts.py +872 -0
- agenttwin/cli.py +655 -0
- agenttwin/diff.py +218 -0
- agenttwin/drift.py +94 -0
- agenttwin/env.py +363 -0
- agenttwin/fingerprint.py +112 -0
- agenttwin/fixtures/cleanup.jsonl +7 -0
- agenttwin/fixtures/cleanup_guarded.jsonl +5 -0
- agenttwin/fixtures/correct_shipping.jsonl +5 -0
- agenttwin/fixtures/deathloop.jsonl +22 -0
- agenttwin/fixtures/double_shipping.jsonl +7 -0
- agenttwin/fixtures/full_receipt.jsonl +5 -0
- agenttwin/fixtures/partial_receipt.jsonl +5 -0
- agenttwin/fixtures/repaired.jsonl +5 -0
- agenttwin/fixtures/search_first.jsonl +7 -0
- agenttwin/fixtures/write_first.jsonl +7 -0
- agenttwin/gallery.py +591 -0
- agenttwin/html/__init__.py +19 -0
- agenttwin/html/replayer.html +1467 -0
- agenttwin/proxy.py +431 -0
- agenttwin/record.py +104 -0
- agenttwin/redact.py +167 -0
- agenttwin/report.py +304 -0
- agenttwin/trace.py +391 -0
- agenttwin-0.1.0.dist-info/LICENSE +202 -0
- agenttwin-0.1.0.dist-info/METADATA +308 -0
- agenttwin-0.1.0.dist-info/RECORD +51 -0
- agenttwin-0.1.0.dist-info/WHEEL +5 -0
- agenttwin-0.1.0.dist-info/entry_points.txt +2 -0
- agenttwin-0.1.0.dist-info/top_level.txt +1 -0
agenttwin/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""AgentTwin -- record one agent run, replay it forever (CONTRACTS section 3).
|
|
2
|
+
|
|
3
|
+
The tape layer (Trace, Event, Header, canonical_json, sha256_hex) is imported
|
|
4
|
+
eagerly. Everything that reaches the replayer is resolved by name on first
|
|
5
|
+
access, so importing agenttwin never depends on the replay side of the library
|
|
6
|
+
being importable -- in particular not on agenttwin.diff.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .drift import ArgsDrift, DriftError, FingerprintDrift, PromptDrift, TapeMiss
|
|
15
|
+
from .redact import Redactor
|
|
16
|
+
from .trace import (
|
|
17
|
+
CLOCK_STEP,
|
|
18
|
+
EVENT_KINDS,
|
|
19
|
+
SCHEMA_VERSION,
|
|
20
|
+
Event,
|
|
21
|
+
Header,
|
|
22
|
+
SchemaError,
|
|
23
|
+
Trace,
|
|
24
|
+
canonical_json,
|
|
25
|
+
sha256_hex,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__: str = "0.1.0"
|
|
29
|
+
|
|
30
|
+
_LAZY: dict[str, str] = {
|
|
31
|
+
"Twin": ".env",
|
|
32
|
+
"TapeRecorder": ".env",
|
|
33
|
+
"ReplaySession": ".env",
|
|
34
|
+
"Report": ".report",
|
|
35
|
+
"TraceDiff": ".diff",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ArgsDrift",
|
|
40
|
+
"CLOCK_STEP",
|
|
41
|
+
"DriftError",
|
|
42
|
+
"EVENT_KINDS",
|
|
43
|
+
"Event",
|
|
44
|
+
"FingerprintDrift",
|
|
45
|
+
"Header",
|
|
46
|
+
"PromptDrift",
|
|
47
|
+
"Redactor",
|
|
48
|
+
"Report",
|
|
49
|
+
"ReplaySession",
|
|
50
|
+
"SCHEMA_VERSION",
|
|
51
|
+
"SchemaError",
|
|
52
|
+
"TapeMiss",
|
|
53
|
+
"TapeRecorder",
|
|
54
|
+
"Trace",
|
|
55
|
+
"TraceDiff",
|
|
56
|
+
"Twin",
|
|
57
|
+
"canonical_json",
|
|
58
|
+
"sha256_hex",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __getattr__(name: str) -> Any:
|
|
63
|
+
"""PEP 562: resolve the replay-side names on first access, then cache them."""
|
|
64
|
+
module = _LAZY.get(name)
|
|
65
|
+
if module is None:
|
|
66
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
67
|
+
value = getattr(import_module(module, __name__), name)
|
|
68
|
+
globals()[name] = value
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def __dir__() -> list[str]:
|
|
73
|
+
return sorted(__all__)
|
agenttwin/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Module entry point so "python -m agenttwin" reaches the CLI (CONTRACTS section 6).
|
|
2
|
+
|
|
3
|
+
The console script installed by pyproject.toml points at agenttwin.cli:main; this
|
|
4
|
+
module exists so the same surface works from a source checkout without an
|
|
5
|
+
install. Keep it to the delegation and the exit status -- everything else,
|
|
6
|
+
including status mapping, belongs to agenttwin.cli.main.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .cli import main
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
raise SystemExit(main())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The adapter layer: record an agent you did not write (design doc 4.2 L1 + L2).
|
|
2
|
+
|
|
3
|
+
``wrap`` and ``install`` speak to any object shaped like the OpenAI SDK client,
|
|
4
|
+
and ``invoke`` calls a controller the way CONTRACTS section 3 defines. No SDK is
|
|
5
|
+
imported anywhere here -- the adapter is duck-typed, so importing the package
|
|
6
|
+
stays free of optional dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .base import AgentCallable, AgentProtocol, AgentRunner, invoke, is_agent
|
|
12
|
+
from .openai_compatible import install, wrap
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AgentCallable",
|
|
16
|
+
"AgentProtocol",
|
|
17
|
+
"AgentRunner",
|
|
18
|
+
"install",
|
|
19
|
+
"invoke",
|
|
20
|
+
"is_agent",
|
|
21
|
+
"wrap",
|
|
22
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The frozen Agent Protocol (CONTRACTS section 3) -- one call shape for Twin.
|
|
2
|
+
|
|
3
|
+
A controller is either ``agent(llm, tools)`` or an object exposing
|
|
4
|
+
``agent.run(llm, tools)``; when its signature also accepts ``policy`` -- by name,
|
|
5
|
+
as the third positional parameter, or through ``*args`` -- Twin passes the policy
|
|
6
|
+
through. ``env.py`` keeps a private copy of that decision (its surface is frozen);
|
|
7
|
+
this module is the standalone version, so an adapter can call a controller without
|
|
8
|
+
importing the record/replay facade.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AgentCallable",
|
|
19
|
+
"AgentProtocol",
|
|
20
|
+
"AgentRunner",
|
|
21
|
+
"is_agent",
|
|
22
|
+
"invoke",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_POSITIONAL_KINDS = (
|
|
26
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
27
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class AgentCallable(Protocol):
|
|
33
|
+
"""CONTRACTS section 3 -- the callable form of a controller: agent(llm, tools)."""
|
|
34
|
+
|
|
35
|
+
def __call__(self, llm: Any, tools: Mapping[str, Any]) -> Any:
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class AgentRunner(Protocol):
|
|
41
|
+
"""CONTRACTS section 3 -- the object form of a controller: agent.run(llm, tools)."""
|
|
42
|
+
|
|
43
|
+
def run(self, llm: Any, tools: Mapping[str, Any]) -> Any:
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# CONTRACTS section 3 allows either form, so the protocol is their union: a single
|
|
48
|
+
# Protocol cannot express it, because every member of a Protocol is required.
|
|
49
|
+
AgentProtocol = AgentCallable | AgentRunner
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_agent(agent: Any) -> bool:
|
|
53
|
+
"""True when invoke() can call agent: the callable form or the .run form (CONTRACTS section 3)."""
|
|
54
|
+
return callable(getattr(agent, "run", None)) or callable(agent)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def invoke(agent: Any, llm: Any, tools: Mapping[str, Any], policy: Any = None) -> Any:
|
|
58
|
+
"""Call agent the way CONTRACTS section 3 defines it and return its result.
|
|
59
|
+
|
|
60
|
+
``run`` wins over ``__call__`` when an object offers both (env.py decides the
|
|
61
|
+
same way). A signature that accepts ``policy`` always receives it, including
|
|
62
|
+
``None``: a controller whose third parameter is required stays callable when
|
|
63
|
+
no policy was supplied.
|
|
64
|
+
"""
|
|
65
|
+
runner = getattr(agent, "run", None)
|
|
66
|
+
fn = runner if callable(runner) else agent
|
|
67
|
+
if not callable(fn):
|
|
68
|
+
raise TypeError(
|
|
69
|
+
"agent must be callable(llm, tools) or expose run(llm, tools); "
|
|
70
|
+
f"got {type(agent).__name__}"
|
|
71
|
+
)
|
|
72
|
+
style = _policy_style(fn)
|
|
73
|
+
if style == "keyword":
|
|
74
|
+
return fn(llm, tools, policy=policy)
|
|
75
|
+
if style == "positional":
|
|
76
|
+
return fn(llm, tools, policy)
|
|
77
|
+
return fn(llm, tools)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _policy_style(fn: Any) -> str | None:
|
|
81
|
+
"""How fn wants the policy: "keyword", "positional", or None when it takes none."""
|
|
82
|
+
try:
|
|
83
|
+
parameters = inspect.signature(fn).parameters
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
# Not introspectable (a C builtin): the documented (llm, tools) form is all we can promise.
|
|
86
|
+
return None
|
|
87
|
+
named = parameters.get("policy")
|
|
88
|
+
if named is not None:
|
|
89
|
+
if named.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
90
|
+
return "positional"
|
|
91
|
+
return "keyword"
|
|
92
|
+
kinds = [parameter.kind for parameter in parameters.values()]
|
|
93
|
+
if inspect.Parameter.VAR_POSITIONAL in kinds:
|
|
94
|
+
return "positional"
|
|
95
|
+
positional = sum(1 for kind in kinds if kind in _POSITIONAL_KINDS)
|
|
96
|
+
return "positional" if positional >= 3 else None
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""The openai-compatible adapter (design doc section 4.2, L1 + L2).
|
|
2
|
+
|
|
3
|
+
Duck-typed on purpose: nothing here imports the SDK, so importing the package
|
|
4
|
+
never pulls one in. ``wrap`` records into a tape the caller already opened;
|
|
5
|
+
``install`` opens its own tape from the environment and writes it when the
|
|
6
|
+
interpreter exits.
|
|
7
|
+
|
|
8
|
+
Recording follows CONTRACTS section 1.4 and 9.20, which replay depends on: the
|
|
9
|
+
request the caller passed is hashed *before* redaction, the preview is the
|
|
10
|
+
redacted and truncated request, and the response is carried as JSON. Nothing
|
|
11
|
+
beyond ``chat.completions.create(**kwargs)`` is modelled: the adapter is not an
|
|
12
|
+
SDK shim.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import atexit
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..env import TapeRecorder, Twin
|
|
25
|
+
from ..proxy import LLM_ACTOR, PREVIEW_LIMIT
|
|
26
|
+
from ..redact import Redactor
|
|
27
|
+
from ..trace import Trace, canonical_json, sha256_hex
|
|
28
|
+
|
|
29
|
+
__all__ = ["RECORD_OUT_ENV", "RECORD_TRACE_ID_ENV", "install", "wrap"]
|
|
30
|
+
|
|
31
|
+
# The L2 zero-config pair. An unset AGENTTWIN_RECORD_OUT is a configuration
|
|
32
|
+
# error, never a silent no-op: recording nowhere is how a tape goes missing.
|
|
33
|
+
RECORD_OUT_ENV: str = "AGENTTWIN_RECORD_OUT"
|
|
34
|
+
RECORD_TRACE_ID_ENV: str = "AGENTTWIN_TRACE_ID"
|
|
35
|
+
|
|
36
|
+
_UNSAFE: Any = object()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def wrap(client: Any, tape: Any = None, *, model: str | None = None) -> Any:
|
|
40
|
+
"""CONTRACTS section 3 -- route client.chat.completions.create through a tape.
|
|
41
|
+
|
|
42
|
+
tape is a TapeRecorder from Twin.record() or a Trace; None keeps the proxy
|
|
43
|
+
transparent. model names the model for calls whose kwargs omit it.
|
|
44
|
+
|
|
45
|
+
A TapeRecorder is told the substitutions this proxy makes, so its header
|
|
46
|
+
counts them (CONTRACTS 9.28). A bare Trace's header is already final and is
|
|
47
|
+
never retroactively recounted: with no recorder there is nothing to rebuild it.
|
|
48
|
+
"""
|
|
49
|
+
_require_client(client)
|
|
50
|
+
return _RecordingClient(
|
|
51
|
+
client, _trace_of(tape), _redactor_of(tape), model, _note_of(tape)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def install(
|
|
56
|
+
client: Any,
|
|
57
|
+
*,
|
|
58
|
+
out: str | Path | None = None,
|
|
59
|
+
trace_id: str | None = None,
|
|
60
|
+
model: str | None = None,
|
|
61
|
+
) -> Any:
|
|
62
|
+
"""Design doc section 4.2 L2 -- zero-config recording for an existing client.
|
|
63
|
+
|
|
64
|
+
out falls back to AGENTTWIN_RECORD_OUT and trace_id to AGENTTWIN_TRACE_ID
|
|
65
|
+
(then to the tape filename). The tape is written when the interpreter
|
|
66
|
+
exits; call flush() on the returned proxy to write it sooner.
|
|
67
|
+
"""
|
|
68
|
+
_require_client(client)
|
|
69
|
+
target = _target(out)
|
|
70
|
+
trace_id = trace_id or _env(RECORD_TRACE_ID_ENV) or target.stem or "trace"
|
|
71
|
+
# The adapter scrubs every preview, so the tape must say so: a header policy of
|
|
72
|
+
# "no patterns" printed next to a scrubbed preview would be a false statement.
|
|
73
|
+
recorder = Twin.record(trace_id, out=target, model=model, redactor=Redactor())
|
|
74
|
+
proxy = _InstalledClient(client, recorder, model)
|
|
75
|
+
atexit.register(proxy.flush)
|
|
76
|
+
return proxy
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- client shape ----------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _require_client(client: Any) -> Any:
|
|
83
|
+
"""Fail at wrap time on a shape no create() call could ever reach."""
|
|
84
|
+
try:
|
|
85
|
+
create = client.chat.completions.create
|
|
86
|
+
except AttributeError:
|
|
87
|
+
raise TypeError(
|
|
88
|
+
"client must look like the OpenAI SDK -- a callable create(**kwargs) on "
|
|
89
|
+
f"client.chat.completions; got {type(client).__name__}"
|
|
90
|
+
) from None
|
|
91
|
+
if not callable(create):
|
|
92
|
+
raise TypeError(f"client.chat.completions.create must be callable; got {type(create).__name__}")
|
|
93
|
+
return client
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _target(out: str | Path | None) -> Path:
|
|
97
|
+
"""The tape path from out or the environment; refuse to record nowhere."""
|
|
98
|
+
raw = out if out is not None else _env(RECORD_OUT_ENV)
|
|
99
|
+
if not raw:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"install() has no tape to write: pass out=... or set "
|
|
102
|
+
f"{RECORD_OUT_ENV} (optionally {RECORD_TRACE_ID_ENV} for the trace id); "
|
|
103
|
+
"refusing to run unrecorded"
|
|
104
|
+
)
|
|
105
|
+
target = Path(raw)
|
|
106
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
return target
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _env(name: str) -> str | None:
|
|
111
|
+
"""An environment variable, with the empty string treated as unset."""
|
|
112
|
+
return os.environ.get(name) or None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _trace_of(tape: Any) -> Trace | None:
|
|
116
|
+
"""The Trace behind a TapeRecorder (or a Trace itself); None records nothing."""
|
|
117
|
+
if tape is None:
|
|
118
|
+
return None
|
|
119
|
+
trace = getattr(tape, "trace", tape)
|
|
120
|
+
if not callable(getattr(trace, "append", None)):
|
|
121
|
+
raise TypeError(f"tape must be a TapeRecorder or a Trace; got {type(tape).__name__}")
|
|
122
|
+
return trace
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _redactor_of(tape: Any) -> Any:
|
|
126
|
+
"""Reuse the recorder's policy; a bare Trace or wrap(tape=None) scrubs by default.
|
|
127
|
+
|
|
128
|
+
The default is the full pattern set rather than "no redaction": the preview is
|
|
129
|
+
the only place a real request is stored, so it must never carry a secret
|
|
130
|
+
(CONTRACTS 9.20).
|
|
131
|
+
"""
|
|
132
|
+
redactor = getattr(tape, "redactor", None)
|
|
133
|
+
if callable(getattr(redactor, "payload", None)):
|
|
134
|
+
return redactor
|
|
135
|
+
return Redactor()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _note_of(tape: Any) -> Any:
|
|
139
|
+
"""The recorder's redaction counter, or None for a bare Trace (CONTRACTS 9.28)."""
|
|
140
|
+
note = getattr(tape, "note_redactions", None)
|
|
141
|
+
return note if callable(note) else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- the proxies -----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _RecordingClient:
|
|
148
|
+
"""A proxy over an openai-compatible client: create() records, the rest delegates."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
client: Any,
|
|
153
|
+
trace: Trace | None,
|
|
154
|
+
redactor: Any,
|
|
155
|
+
model: str | None,
|
|
156
|
+
note: Any = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._client = client
|
|
159
|
+
self._trace = trace
|
|
160
|
+
self._redactor = redactor
|
|
161
|
+
self._model = model
|
|
162
|
+
self._note = note
|
|
163
|
+
self._chat = _ChatProxy(self)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def chat(self) -> "_ChatProxy":
|
|
167
|
+
"""The client's chat namespace, whose completions.create records the call."""
|
|
168
|
+
return self._chat
|
|
169
|
+
|
|
170
|
+
def __getattr__(self, name: str) -> Any:
|
|
171
|
+
"""CONTRACTS section 3 -- everything that is not create() is the real client's."""
|
|
172
|
+
try:
|
|
173
|
+
client = object.__getattribute__(self, "_client")
|
|
174
|
+
except AttributeError:
|
|
175
|
+
raise AttributeError(name) from None
|
|
176
|
+
return getattr(client, name)
|
|
177
|
+
|
|
178
|
+
def __repr__(self) -> str:
|
|
179
|
+
return f"<agenttwin adapter for {type(self._client).__name__}>"
|
|
180
|
+
|
|
181
|
+
def _create(self, kwargs: dict) -> Any:
|
|
182
|
+
"""One create() in, one llm_call event out; the real response is returned.
|
|
183
|
+
|
|
184
|
+
The event is appended only after the real call returns, so a call that
|
|
185
|
+
raises records nothing -- the same rule proxy.RecordingLLM follows.
|
|
186
|
+
"""
|
|
187
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
188
|
+
if self._trace is not None:
|
|
189
|
+
self._trace.append("llm_call", LLM_ACTOR, self._payload(kwargs, response))
|
|
190
|
+
return response
|
|
191
|
+
|
|
192
|
+
def _payload(self, request: dict, response: Any) -> dict:
|
|
193
|
+
"""CONTRACTS section 1.4 -- the llm_call payload, hashed before redaction."""
|
|
194
|
+
payload: dict[str, Any] = {
|
|
195
|
+
"prompt_hash": sha256_hex(canonical_json(request)),
|
|
196
|
+
"response": _json_safe(response),
|
|
197
|
+
"prompt_preview": canonical_json(request),
|
|
198
|
+
}
|
|
199
|
+
model = request.get("model")
|
|
200
|
+
if model is None:
|
|
201
|
+
model = self._model
|
|
202
|
+
if model is not None:
|
|
203
|
+
payload["model"] = model
|
|
204
|
+
return _scrub("llm_call", payload, self._redactor, self._note)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _ChatProxy:
|
|
208
|
+
"""client.chat: completions records, every other attribute is the real namespace's."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, owner: _RecordingClient) -> None:
|
|
211
|
+
self._owner = owner
|
|
212
|
+
self._completions = _CompletionsProxy(owner)
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def completions(self) -> "_CompletionsProxy":
|
|
216
|
+
"""The recording completions namespace."""
|
|
217
|
+
return self._completions
|
|
218
|
+
|
|
219
|
+
def __getattr__(self, name: str) -> Any:
|
|
220
|
+
return getattr(self._owner._client.chat, name)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _CompletionsProxy:
|
|
224
|
+
"""client.chat.completions: create() records, every other attribute delegates."""
|
|
225
|
+
|
|
226
|
+
def __init__(self, owner: _RecordingClient) -> None:
|
|
227
|
+
self._owner = owner
|
|
228
|
+
|
|
229
|
+
def create(self, **kwargs: Any) -> Any:
|
|
230
|
+
"""CONTRACTS section 1.4 -- one call in, one llm_call event out."""
|
|
231
|
+
return self._owner._create(kwargs)
|
|
232
|
+
|
|
233
|
+
def __getattr__(self, name: str) -> Any:
|
|
234
|
+
return getattr(self._owner._client.chat.completions, name)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class _InstalledClient(_RecordingClient):
|
|
238
|
+
"""install()'s proxy: it owns the tape, so it decides when the tape is written."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, client: Any, recorder: TapeRecorder, model: str | None) -> None:
|
|
241
|
+
self._recorder = recorder
|
|
242
|
+
super().__init__(
|
|
243
|
+
client, recorder.trace, _redactor_of(recorder), model, _note_of(recorder)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def flush(self) -> None:
|
|
247
|
+
"""Write the tape now, terminal event included; calling it twice is harmless."""
|
|
248
|
+
self._recorder.__exit__(None, None, None)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --- payload helpers -------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _scrub(kind: str, payload: dict, redactor: Any, note: Any = None) -> dict:
|
|
255
|
+
"""CONTRACTS 9.20 -- hashes stay verbatim, previews are scrubbed then truncated.
|
|
256
|
+
|
|
257
|
+
A sha256 is itself a 32+ hex run, so redaction would otherwise rewrite the
|
|
258
|
+
very value replay compares; proxy.py splits the two for the same reason.
|
|
259
|
+
|
|
260
|
+
note, when given, receives the substitution count so the tape header can
|
|
261
|
+
report it (CONTRACTS 9.28); a bare Trace has no recorder to tell.
|
|
262
|
+
"""
|
|
263
|
+
hashes = {key: value for key, value in payload.items() if key.endswith("_hash")}
|
|
264
|
+
scrub = {key: value for key, value in payload.items() if not key.endswith("_hash")}
|
|
265
|
+
scrubbed, count = redactor.payload(kind, scrub)
|
|
266
|
+
if note is not None:
|
|
267
|
+
note(count)
|
|
268
|
+
scrubbed.update(hashes)
|
|
269
|
+
for key, value in scrubbed.items():
|
|
270
|
+
if key.endswith("_preview") and isinstance(value, str) and len(value) > PREVIEW_LIMIT:
|
|
271
|
+
scrubbed[key] = value[:PREVIEW_LIMIT]
|
|
272
|
+
return scrubbed
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _json_safe(response: Any) -> Any:
|
|
276
|
+
"""CONTRACTS section 1.4 -- the response as something the tape can carry.
|
|
277
|
+
|
|
278
|
+
model_dump(), to_dict(), a mapping, the JSON-safe entries of __dict__, then
|
|
279
|
+
{"repr": ...}: a tape line always names what came back instead of failing
|
|
280
|
+
the recording on an SDK object.
|
|
281
|
+
"""
|
|
282
|
+
for name in ("model_dump", "to_dict"):
|
|
283
|
+
value = _called_json(response, name)
|
|
284
|
+
if value is not _UNSAFE:
|
|
285
|
+
return value
|
|
286
|
+
if isinstance(response, Mapping):
|
|
287
|
+
value = _round_trip(dict(response))
|
|
288
|
+
if value is not _UNSAFE:
|
|
289
|
+
return value
|
|
290
|
+
value = _attributes_json(response)
|
|
291
|
+
if value is not _UNSAFE:
|
|
292
|
+
return value
|
|
293
|
+
return {"repr": repr(response)}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _called_json(response: Any, name: str) -> Any:
|
|
297
|
+
"""response.name() round-tripped, or _UNSAFE when absent, raising or not JSON."""
|
|
298
|
+
try:
|
|
299
|
+
method = getattr(response, name, None)
|
|
300
|
+
except Exception:
|
|
301
|
+
return _UNSAFE
|
|
302
|
+
if not callable(method):
|
|
303
|
+
return _UNSAFE
|
|
304
|
+
try:
|
|
305
|
+
return _round_trip(method())
|
|
306
|
+
except Exception:
|
|
307
|
+
return _UNSAFE
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _attributes_json(response: Any) -> Any:
|
|
311
|
+
"""The __dict__ entries JSON can represent, or _UNSAFE when there are none."""
|
|
312
|
+
try:
|
|
313
|
+
data = vars(response)
|
|
314
|
+
except TypeError:
|
|
315
|
+
return _UNSAFE
|
|
316
|
+
picked = {
|
|
317
|
+
key: value
|
|
318
|
+
for key, value in data.items()
|
|
319
|
+
if isinstance(key, str) and _round_trip(value) is not _UNSAFE
|
|
320
|
+
}
|
|
321
|
+
return _round_trip(picked) if picked else _UNSAFE
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _round_trip(value: Any) -> Any:
|
|
325
|
+
"""value through json and back, or _UNSAFE when json cannot represent it."""
|
|
326
|
+
try:
|
|
327
|
+
return json.loads(json.dumps(value, sort_keys=True))
|
|
328
|
+
except (TypeError, ValueError):
|
|
329
|
+
return _UNSAFE
|