agentsnap 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.
agentsnap/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from agentsnap import config
2
+ from agentsnap.core.asserter import AgentAsserter
3
+ from agentsnap.core.diff import LLMJudge
4
+ from agentsnap.core.recorder import AgentRecorder
5
+ from agentsnap.exceptions import (
6
+ AdapterNotWrappedError,
7
+ AgentRegressionError,
8
+ SnapshotNotFoundError,
9
+ )
10
+ from agentsnap.patches import PatchSet
11
+ from agentsnap.wrap import wrap
12
+
13
+ __all__ = [
14
+ "wrap",
15
+ "AgentRecorder",
16
+ "AgentAsserter",
17
+ "LLMJudge",
18
+ "PatchSet",
19
+ "AgentRegressionError",
20
+ "SnapshotNotFoundError",
21
+ "AdapterNotWrappedError",
22
+ "config",
23
+ ]
File without changes
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.core.recorder import TraceAccumulator
4
+
5
+
6
+ class _MessagesProxy:
7
+ def __init__(self, original) -> None:
8
+ self._original = original
9
+
10
+ def create(self, **kwargs):
11
+ acc = TraceAccumulator.current()
12
+ if acc is None:
13
+ return self._original.create(**kwargs)
14
+
15
+ messages = kwargs.get("messages", [])
16
+ response = self._original.create(**kwargs)
17
+
18
+ response_text = ""
19
+ tokens = 0
20
+ if hasattr(response, "content"):
21
+ for block in response.content:
22
+ if hasattr(block, "text"):
23
+ response_text += block.text
24
+ if hasattr(response, "usage"):
25
+ tokens = getattr(response.usage, "input_tokens", 0) + getattr(
26
+ response.usage, "output_tokens", 0
27
+ )
28
+
29
+ acc.push(
30
+ {
31
+ "type": "llm_call",
32
+ "messages": messages,
33
+ "response": response_text,
34
+ "tokens": tokens,
35
+ }
36
+ )
37
+ return response
38
+
39
+ def __getattr__(self, name: str):
40
+ return getattr(self._original, name)
41
+
42
+
43
+ class AnthropicAdapter:
44
+ """Wraps an anthropic.Anthropic() client to intercept .messages.create()."""
45
+
46
+ def __init__(self, client) -> None:
47
+ self._client = client
48
+ self.messages = _MessagesProxy(client.messages)
49
+
50
+ def __getattr__(self, name: str):
51
+ return getattr(self._client, name)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.core.recorder import TraceAccumulator
4
+
5
+
6
+ class _CohereV2ChatProxy:
7
+ """Intercepts cohere.ClientV2().chat()."""
8
+
9
+ def __init__(self, original_chat_fn) -> None:
10
+ self._original = original_chat_fn
11
+
12
+ def __call__(self, **kwargs):
13
+ acc = TraceAccumulator.current()
14
+ if acc is None:
15
+ return self._original(**kwargs)
16
+
17
+ messages = kwargs.get("messages", [])
18
+ response = self._original(**kwargs)
19
+
20
+ response_text = ""
21
+ tokens = 0
22
+ if hasattr(response, "message") and hasattr(response.message, "content"):
23
+ for block in response.message.content:
24
+ if hasattr(block, "text"):
25
+ response_text += block.text
26
+ if hasattr(response, "usage"):
27
+ tokens = getattr(response.usage, "tokens", None)
28
+ if tokens is None:
29
+ inp = getattr(response.usage, "input_tokens", 0) or 0
30
+ out = getattr(response.usage, "output_tokens", 0) or 0
31
+ tokens = inp + out
32
+
33
+ acc.push(
34
+ {
35
+ "type": "llm_call",
36
+ "messages": messages,
37
+ "response": response_text,
38
+ "tokens": tokens or 0,
39
+ }
40
+ )
41
+ return response
42
+
43
+
44
+ class CohereAdapter:
45
+ """Wraps a cohere.ClientV2() to intercept .chat().
46
+
47
+ Usage:
48
+ import cohere
49
+ from agentsnap.adapters.cohere import CohereAdapter
50
+ client = CohereAdapter(cohere.ClientV2(api_key="..."))
51
+ response = client.chat(model="command-r-plus", messages=[...])
52
+ """
53
+
54
+ def __init__(self, client) -> None:
55
+ self._client = client
56
+ self.chat = _CohereV2ChatProxy(client.chat)
57
+
58
+ def __getattr__(self, name: str):
59
+ return getattr(self._client, name)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.core.recorder import TraceAccumulator
4
+
5
+
6
+ class _ModelsProxy:
7
+ def __init__(self, original) -> None:
8
+ self._original = original
9
+
10
+ def generate_content(self, model: str, contents, **kwargs):
11
+ acc = TraceAccumulator.current()
12
+ if acc is None:
13
+ return self._original.generate_content(model=model, contents=contents, **kwargs)
14
+
15
+ if isinstance(contents, str):
16
+ messages = [{"role": "user", "content": contents}]
17
+ elif isinstance(contents, list):
18
+ messages = contents
19
+ else:
20
+ messages = [{"role": "user", "content": str(contents)}]
21
+
22
+ response = self._original.generate_content(model=model, contents=contents, **kwargs)
23
+
24
+ response_text = ""
25
+ tokens = 0
26
+ if hasattr(response, "text"):
27
+ response_text = response.text or ""
28
+ elif hasattr(response, "candidates") and response.candidates:
29
+ for part in response.candidates[0].content.parts:
30
+ if hasattr(part, "text"):
31
+ response_text += part.text
32
+ if hasattr(response, "usage_metadata"):
33
+ tokens = getattr(response.usage_metadata, "total_token_count", 0)
34
+
35
+ acc.push(
36
+ {
37
+ "type": "llm_call",
38
+ "messages": messages,
39
+ "response": response_text,
40
+ "tokens": tokens,
41
+ }
42
+ )
43
+ return response
44
+
45
+ def __getattr__(self, name: str):
46
+ return getattr(self._original, name)
47
+
48
+
49
+ class GeminiAdapter:
50
+ """Wraps a google.genai.Client() to intercept .models.generate_content().
51
+
52
+ Usage:
53
+ from google import genai
54
+ from agentsnap.adapters.google import GeminiAdapter
55
+ client = GeminiAdapter(genai.Client(api_key="..."))
56
+ response = client.models.generate_content(model="gemini-2.0-flash", contents="Hello")
57
+ """
58
+
59
+ def __init__(self, client) -> None:
60
+ self._client = client
61
+ self.models = _ModelsProxy(client.models)
62
+
63
+ def __getattr__(self, name: str):
64
+ return getattr(self._client, name)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.adapters.openai import OpenAIAdapter
4
+
5
+
6
+ class GroqAdapter(OpenAIAdapter):
7
+ """Wraps a groq.Groq() client (OpenAI-compatible interface).
8
+
9
+ Groq uses the same .chat.completions.create() interface as OpenAI,
10
+ so this is a thin alias over OpenAIAdapter.
11
+
12
+ Usage:
13
+ from groq import Groq
14
+ from agentsnap.adapters.groq import GroqAdapter
15
+ client = GroqAdapter(Groq(api_key="..."))
16
+ response = client.chat.completions.create(
17
+ model="llama-3.3-70b-versatile", messages=[...]
18
+ )
19
+ """
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.core.recorder import TraceAccumulator
4
+
5
+ try:
6
+ from langchain_core.callbacks import BaseCallbackHandler as _Base
7
+ except ImportError:
8
+ _Base = object # type: ignore[assignment,misc]
9
+
10
+
11
+ class AgentSnapCallback(_Base):
12
+ """LangChain callback handler that records LLM and tool events into TraceAccumulator.
13
+
14
+ Injected automatically by LangGraphAdapter. Also works standalone with any
15
+ LangChain model or chain that supports the callbacks API.
16
+
17
+ When langchain_core is not installed, _Base is object — the class is still
18
+ importable and its methods callable; it just won't be registered as a real
19
+ LangChain callback (duck-typing makes it work with our mock graphs in tests).
20
+ """
21
+
22
+ def on_llm_end(self, response, **kwargs) -> None:
23
+ acc = TraceAccumulator.current()
24
+ if acc is None:
25
+ return
26
+ text = ""
27
+ if hasattr(response, "generations") and response.generations:
28
+ gen = response.generations[0][0]
29
+ if hasattr(gen, "text") and gen.text:
30
+ text = gen.text
31
+ elif hasattr(gen, "message") and hasattr(gen.message, "content"):
32
+ text = gen.message.content or ""
33
+ else:
34
+ text = str(gen)
35
+ acc.push({"type": "llm_call", "messages": [], "response": text, "tokens": 0})
36
+
37
+ def on_tool_end(self, output, *, name: str = "", **kwargs) -> None:
38
+ acc = TraceAccumulator.current()
39
+ if acc is None:
40
+ return
41
+ acc.push({"type": "tool_call", "name": name, "args": {}, "result": str(output)})
42
+
43
+
44
+ class LangGraphAdapter:
45
+ """Wraps a CompiledGraph to capture traces via the LangChain callbacks system.
46
+
47
+ Injects AgentSnapCallback into every invoke() call so that LLM and tool
48
+ events from individual graph nodes are recorded into the active
49
+ TraceAccumulator.
50
+
51
+ Falls back to recording the top-level result as a single llm_call event
52
+ when langchain_core is not installed (no callbacks support available).
53
+ """
54
+
55
+ def __init__(self, graph) -> None:
56
+ self._graph = graph
57
+
58
+ def invoke(self, input_data, **kwargs):
59
+ acc = TraceAccumulator.current()
60
+ if acc is None:
61
+ return self._graph.invoke(input_data, **kwargs)
62
+
63
+ # Inject AgentSnapCallback via config so node-level events are captured.
64
+ # AgentSnapCallback is always usable via duck-typing even without langchain_core.
65
+ # When langchain_core is absent (_Base is object), we still inject the callback
66
+ # so that graphs that accept a callbacks list (e.g. LangGraph) fire it correctly.
67
+ # The top-level fallback push only runs when langchain_core is absent, because
68
+ # in that case a real CompiledGraph won't know to call on_llm_end/on_tool_end.
69
+ config = dict(kwargs.pop("config", None) or {})
70
+ callbacks = list(config.get("callbacks") or [])
71
+ callbacks.append(AgentSnapCallback())
72
+ config["callbacks"] = callbacks
73
+
74
+ if _Base is not object:
75
+ # langchain_core available: callback will be invoked by the real runtime
76
+ return self._graph.invoke(input_data, config=config, **kwargs)
77
+
78
+ # Fallback: langchain_core absent — real CompiledGraph won't fire callbacks,
79
+ # so record the top-level invocation as a single llm_call event instead.
80
+ result = self._graph.invoke(input_data, config=config, **kwargs)
81
+ if not any(e["type"] == "llm_call" for e in acc.trace):
82
+ acc.push({
83
+ "type": "llm_call",
84
+ "messages": [{"role": "user", "content": str(input_data)}],
85
+ "response": str(result),
86
+ "tokens": 0,
87
+ })
88
+ return result
89
+
90
+ def stream(self, input_data, **kwargs):
91
+ return self._graph.stream(input_data, **kwargs)
92
+
93
+ def __getattr__(self, name: str):
94
+ return getattr(self._graph, name)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.core.recorder import TraceAccumulator
4
+
5
+
6
+ class _MistralCompletionsProxy:
7
+ def __init__(self, original) -> None:
8
+ self._original = original
9
+
10
+ def complete(self, **kwargs):
11
+ acc = TraceAccumulator.current()
12
+ if acc is None:
13
+ return self._original.complete(**kwargs)
14
+
15
+ messages = kwargs.get("messages", [])
16
+ kwargs["stream"] = False
17
+ response = self._original.complete(**kwargs)
18
+
19
+ response_text = ""
20
+ tokens = 0
21
+ if hasattr(response, "choices") and response.choices:
22
+ msg = response.choices[0].message
23
+ response_text = msg.content or ""
24
+ if hasattr(response, "usage"):
25
+ tokens = getattr(response.usage, "total_tokens", 0) or 0
26
+
27
+ acc.push(
28
+ {
29
+ "type": "llm_call",
30
+ "messages": messages,
31
+ "response": response_text,
32
+ "tokens": tokens,
33
+ }
34
+ )
35
+ return response
36
+
37
+ def __getattr__(self, name: str):
38
+ return getattr(self._original, name)
39
+
40
+
41
+ class _MistralChatProxy:
42
+ def __init__(self, chat) -> None:
43
+ self._chat = chat
44
+ self.complete = _MistralCompletionsProxy(chat).complete
45
+
46
+ def __getattr__(self, name: str):
47
+ return getattr(self._chat, name)
48
+
49
+
50
+ class MistralAdapter:
51
+ """Wraps a mistralai.Mistral() client to intercept .chat.complete().
52
+
53
+ Usage:
54
+ from mistralai import Mistral
55
+ from agentsnap.adapters.mistral import MistralAdapter
56
+ client = MistralAdapter(Mistral(api_key="..."))
57
+ response = client.chat.complete(model="mistral-large-latest", messages=[...])
58
+ """
59
+
60
+ def __init__(self, client) -> None:
61
+ self._client = client
62
+ self.chat = _MistralChatProxy(client.chat)
63
+
64
+ def __getattr__(self, name: str):
65
+ return getattr(self._client, name)
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.core.recorder import TraceAccumulator
4
+
5
+
6
+ class _CompletionsProxy:
7
+ def __init__(self, original) -> None:
8
+ self._original = original
9
+
10
+ def create(self, **kwargs):
11
+ acc = TraceAccumulator.current()
12
+ if acc is None:
13
+ return self._original.create(**kwargs)
14
+
15
+ messages = kwargs.get("messages", [])
16
+ # Force non-streaming so we always get a complete ChatCompletion object.
17
+ # Streaming responses expose deltas, not the full message content.
18
+ kwargs["stream"] = False
19
+ response = self._original.create(**kwargs)
20
+
21
+ response_text = ""
22
+ tokens = 0
23
+ if hasattr(response, "choices") and response.choices:
24
+ msg = response.choices[0].message
25
+ response_text = msg.content or ""
26
+ if hasattr(response, "usage"):
27
+ tokens = getattr(response.usage, "total_tokens", 0)
28
+
29
+ acc.push(
30
+ {
31
+ "type": "llm_call",
32
+ "messages": messages,
33
+ "response": response_text,
34
+ "tokens": tokens,
35
+ }
36
+ )
37
+ return response
38
+
39
+ def __getattr__(self, name: str):
40
+ return getattr(self._original, name)
41
+
42
+
43
+ class _ChatProxy:
44
+ def __init__(self, chat) -> None:
45
+ self._chat = chat
46
+ self.completions = _CompletionsProxy(chat.completions)
47
+
48
+ def __getattr__(self, name: str):
49
+ return getattr(self._chat, name)
50
+
51
+
52
+ class OpenAIAdapter:
53
+ """Wraps an openai.OpenAI() client to intercept .chat.completions.create()."""
54
+
55
+ def __init__(self, client) -> None:
56
+ self._client = client
57
+ self.chat = _ChatProxy(client.chat)
58
+
59
+ def __getattr__(self, name: str):
60
+ return getattr(self._client, name)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from agentsnap.adapters.openai import OpenAIAdapter
4
+
5
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
6
+
7
+
8
+ class OpenRouterAdapter(OpenAIAdapter):
9
+ """Wraps an openai.OpenAI() client pointed at OpenRouter.
10
+
11
+ OpenRouter exposes an OpenAI-compatible API that proxies 300+ models
12
+ (Claude, GPT, Gemini, Llama, Mistral, etc.) under one key.
13
+
14
+ Usage:
15
+ import openai
16
+ from agentsnap.adapters.openrouter import OpenRouterAdapter, OPENROUTER_BASE_URL
17
+
18
+ client = OpenRouterAdapter(
19
+ openai.OpenAI(
20
+ api_key="sk-or-...",
21
+ base_url=OPENROUTER_BASE_URL,
22
+ )
23
+ )
24
+ response = client.chat.completions.create(
25
+ model="anthropic/claude-haiku-4-5", # or any OpenRouter model slug
26
+ messages=[{"role": "user", "content": "Hello"}],
27
+ )
28
+ """
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from agentsnap.core.recorder import TraceAccumulator
6
+
7
+
8
+ class ToolAdapter:
9
+ """Wraps any callable to intercept and record tool calls."""
10
+
11
+ def __init__(self, func: Callable, name: str | None = None) -> None:
12
+ self._func = func
13
+ self._name = name or getattr(func, "__name__", "unknown_tool")
14
+
15
+ def __call__(self, **kwargs: Any) -> Any:
16
+ acc = TraceAccumulator.current()
17
+ if acc is None:
18
+ return self._func(**kwargs)
19
+
20
+ result = self._func(**kwargs)
21
+ acc.push(
22
+ {
23
+ "type": "tool_call",
24
+ "name": self._name,
25
+ "args": kwargs,
26
+ "result": str(result),
27
+ }
28
+ )
29
+ return result
30
+
31
+ @property
32
+ def __name__(self) -> str:
33
+ return self._name
34
+
35
+ def __repr__(self) -> str:
36
+ return f"ToolAdapter({self._name!r})"