brooder 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.
- brooder/__init__.py +31 -0
- brooder/analysis.py +79 -0
- brooder/cli.py +281 -0
- brooder/config.py +88 -0
- brooder/diffing.py +217 -0
- brooder/errors.py +31 -0
- brooder/integrations/__init__.py +75 -0
- brooder/integrations/anthropic.py +46 -0
- brooder/integrations/base.py +170 -0
- brooder/integrations/bedrock.py +49 -0
- brooder/integrations/claude_agent.py +164 -0
- brooder/integrations/google.py +61 -0
- brooder/integrations/langchain.py +321 -0
- brooder/integrations/openai.py +43 -0
- brooder/integrations/openai_agents.py +208 -0
- brooder/integrations/otel.py +216 -0
- brooder/judges.py +109 -0
- brooder/log.py +33 -0
- brooder/metrics.py +116 -0
- brooder/models.py +148 -0
- brooder/py.typed +1 -0
- brooder/recorder.py +342 -0
- brooder/report.py +261 -0
- brooder/storage.py +150 -0
- brooder-0.1.0.dist-info/METADATA +338 -0
- brooder-0.1.0.dist-info/RECORD +30 -0
- brooder-0.1.0.dist-info/WHEEL +4 -0
- brooder-0.1.0.dist-info/entry_points.txt +2 -0
- brooder-0.1.0.dist-info/licenses/LICENSE +201 -0
- brooder-0.1.0.dist-info/licenses/NOTICE +7 -0
brooder/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Typed, user-facing exceptions.
|
|
2
|
+
|
|
3
|
+
Anything raised as a ``BrooderError`` is considered safe to print to the user (no stack
|
|
4
|
+
trace). The CLI's single error boundary (``cli.main``) catches these and exits cleanly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BrooderError(Exception):
|
|
11
|
+
"""Base class for expected, user-facing Brooder errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScriptNotFoundError(BrooderError):
|
|
15
|
+
"""The target agent script does not exist."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CorruptRecordError(BrooderError):
|
|
19
|
+
"""A stored baseline or run file could not be parsed as a Brooder record."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(BrooderError):
|
|
23
|
+
"""brooder.yaml is present but invalid."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunawayError(Exception):
|
|
27
|
+
"""Internal control-flow signal: a run exceeded ``trajectory.max_steps`` and was aborted.
|
|
28
|
+
|
|
29
|
+
Deliberately **not** a :class:`BrooderError`: it is always caught by the ``@record`` wrapper
|
|
30
|
+
(which turns it into a recorded ``runaway`` run) and must never reach the CLI error boundary.
|
|
31
|
+
"""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Provider auto-capture.
|
|
2
|
+
|
|
3
|
+
Wrap an LLM client once and Brooder records the model's tool-call decisions automatically —
|
|
4
|
+
no manual :func:`brooder.tool_call`. Supported: OpenAI, Azure OpenAI, Anthropic, AWS Bedrock,
|
|
5
|
+
and Google (Gemini / Vertex).
|
|
6
|
+
|
|
7
|
+
import brooder, openai
|
|
8
|
+
client = brooder.instrument(openai.OpenAI())
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from ..errors import BrooderError
|
|
17
|
+
from . import anthropic, bedrock, google, openai
|
|
18
|
+
|
|
19
|
+
# User-facing provider name -> internal adapter key.
|
|
20
|
+
_ALIASES = {
|
|
21
|
+
"openai": "openai",
|
|
22
|
+
"azure": "openai",
|
|
23
|
+
"anthropic": "anthropic",
|
|
24
|
+
"bedrock": "bedrock",
|
|
25
|
+
"aws": "bedrock",
|
|
26
|
+
"google": "google",
|
|
27
|
+
"gcp": "google",
|
|
28
|
+
"vertex": "google",
|
|
29
|
+
"gemini": "google",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Adapter key -> its instrument function.
|
|
33
|
+
_ADAPTERS: dict[str, Callable[..., Any]] = {
|
|
34
|
+
"openai": openai.instrument,
|
|
35
|
+
"anthropic": anthropic.instrument,
|
|
36
|
+
"bedrock": bedrock.instrument,
|
|
37
|
+
"google": google.instrument,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _detect(client: Any) -> str:
|
|
42
|
+
"""Infer the provider key from a client, or raise if it can't be determined."""
|
|
43
|
+
root = (type(client).__module__ or "").split(".")[0]
|
|
44
|
+
if root == "openai":
|
|
45
|
+
return "openai"
|
|
46
|
+
if root == "anthropic":
|
|
47
|
+
return "anthropic"
|
|
48
|
+
if root in ("botocore", "boto3") or hasattr(client, "converse"):
|
|
49
|
+
return "bedrock"
|
|
50
|
+
if root in ("google", "vertexai") or hasattr(client, "generate_content"):
|
|
51
|
+
return "google"
|
|
52
|
+
raise BrooderError("could not detect provider from client; pass provider=... explicitly")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def instrument(client: Any, provider: Optional[str] = None, capture_content: bool = False) -> Any:
|
|
56
|
+
"""Instrument an LLM client so Brooder auto-records the model's tool calls.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
client: A provider SDK client — OpenAI/AzureOpenAI, Anthropic, a boto3 Bedrock-runtime
|
|
60
|
+
client, or a Google ``GenerativeModel``.
|
|
61
|
+
provider: Force a provider (``"openai"``, ``"azure"``, ``"anthropic"``, ``"bedrock"``/
|
|
62
|
+
``"aws"``, ``"google"``/``"gcp"``/``"vertex"``/``"gemini"``). Auto-detected if omitted.
|
|
63
|
+
capture_content: Also record assistant text, not just tool calls.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The same client, with the relevant method patched in place.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
BrooderError: If the provider cannot be resolved.
|
|
70
|
+
"""
|
|
71
|
+
key = provider.lower() if provider else _detect(client)
|
|
72
|
+
resolved = _ALIASES.get(key)
|
|
73
|
+
if resolved is None:
|
|
74
|
+
raise BrooderError(f"unknown provider: {provider!r}")
|
|
75
|
+
return _ADAPTERS[resolved](client, capture_content=capture_content)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Anthropic auto-capture.
|
|
2
|
+
|
|
3
|
+
Wraps ``client.messages.create`` and normalizes the content-block list (``text`` and
|
|
4
|
+
``tool_use`` blocks). Also covers the Anthropic-on-Bedrock / Vertex clients from the same SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from .base import NormalizedCall, ToolRequest, as_dict, get, wrap
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize(_kwargs: dict[str, Any], response: Any) -> Optional[NormalizedCall]:
|
|
15
|
+
texts: list[str] = []
|
|
16
|
+
tool_calls: list[ToolRequest] = []
|
|
17
|
+
for block in get(response, "content") or []:
|
|
18
|
+
kind = get(block, "type")
|
|
19
|
+
if kind == "text":
|
|
20
|
+
text = get(block, "text")
|
|
21
|
+
if text:
|
|
22
|
+
texts.append(text)
|
|
23
|
+
elif kind == "tool_use":
|
|
24
|
+
tool_calls.append(
|
|
25
|
+
ToolRequest(name=get(block, "name") or "", arguments=as_dict(get(block, "input")))
|
|
26
|
+
)
|
|
27
|
+
return NormalizedCall(
|
|
28
|
+
provider="anthropic",
|
|
29
|
+
model=get(response, "model"),
|
|
30
|
+
content="".join(texts) or None,
|
|
31
|
+
tool_calls=tool_calls,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def instrument(client: Any, capture_content: bool = False) -> Any:
|
|
36
|
+
"""Instrument an Anthropic client's ``messages.create``.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
client: An ``Anthropic`` (or ``AnthropicBedrock`` / ``AnthropicVertex``) client.
|
|
40
|
+
capture_content: Also record assistant text, not just tool calls.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The same client, patched in place.
|
|
44
|
+
"""
|
|
45
|
+
wrap(client.messages, "create", _normalize, capture_content)
|
|
46
|
+
return client
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Provider-agnostic core for auto-capture.
|
|
2
|
+
|
|
3
|
+
Each provider adapter turns its SDK's response into a :class:`NormalizedCall`, and :func:`wrap`
|
|
4
|
+
patches the SDK method so every call is normalized and recorded into the active run. Capture is
|
|
5
|
+
best-effort: if normalization ever fails it is logged at debug level and the user's original call
|
|
6
|
+
result is returned unchanged — instrumentation must never break the app it observes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import weakref
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from .. import recorder
|
|
20
|
+
from ..errors import RunawayError
|
|
21
|
+
from ..log import get_logger
|
|
22
|
+
|
|
23
|
+
_log = get_logger()
|
|
24
|
+
|
|
25
|
+
# Methods we've already wrapped, so instrumenting twice is a no-op.
|
|
26
|
+
_wrapped: weakref.WeakSet[Any] = weakref.WeakSet()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ToolRequest:
|
|
31
|
+
"""A tool/function the model asked to call, in provider-neutral form."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class NormalizedCall:
|
|
39
|
+
"""A single model call normalized across providers."""
|
|
40
|
+
|
|
41
|
+
provider: str
|
|
42
|
+
model: Optional[str] = None
|
|
43
|
+
content: Optional[str] = None
|
|
44
|
+
tool_calls: list[ToolRequest] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# A capture function receives the call kwargs and the raw response and returns a NormalizedCall
|
|
48
|
+
# (or None to skip). It must not raise for expected shapes.
|
|
49
|
+
CaptureFn = Callable[[dict[str, Any], Any], Optional[NormalizedCall]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def record_call(call: NormalizedCall, capture_content: bool = False) -> None:
|
|
53
|
+
"""Record a normalized model call into the active run's trajectory.
|
|
54
|
+
|
|
55
|
+
Records a ``TURN`` step for the call (so turn counts are captured), then a tool-call step for
|
|
56
|
+
each tool the model requested (matching manual :func:`brooder.tool_call` semantics), so
|
|
57
|
+
behavioral regressions in tool selection are caught. The model name is deliberately *not*
|
|
58
|
+
recorded, so switching models is not itself a diff.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
call: The normalized call.
|
|
62
|
+
capture_content: If ``True``, also record the assistant's text content (only when it made
|
|
63
|
+
no tool calls). Off by default to keep diffs focused on tool decisions.
|
|
64
|
+
"""
|
|
65
|
+
recorder.turn({"provider": call.provider})
|
|
66
|
+
for tool in call.tool_calls:
|
|
67
|
+
recorder.tool_call(tool.name, tool.arguments)
|
|
68
|
+
if capture_content and call.content is not None and not call.tool_calls:
|
|
69
|
+
recorder.tool_call(f"llm:{call.provider}", {}, result=call.content)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _capture_into_run(
|
|
73
|
+
holder: Any,
|
|
74
|
+
attr: str,
|
|
75
|
+
capture: CaptureFn,
|
|
76
|
+
kwargs: dict[str, Any],
|
|
77
|
+
response: Any,
|
|
78
|
+
capture_content: bool,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Normalize ``response`` and record it into the active run — best-effort.
|
|
81
|
+
|
|
82
|
+
Shared by the sync and async wrappers. A capture failure is logged and swallowed so
|
|
83
|
+
instrumentation never breaks the user's call; only :class:`RunawayError` (the ``max_steps``
|
|
84
|
+
guardrail) is allowed to propagate.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
call = capture(kwargs, response)
|
|
88
|
+
if call is not None:
|
|
89
|
+
record_call(call, capture_content=capture_content)
|
|
90
|
+
except RunawayError:
|
|
91
|
+
# The max_steps guardrail must abort the run, not be swallowed as a capture failure.
|
|
92
|
+
raise
|
|
93
|
+
except Exception:
|
|
94
|
+
# Capture is best-effort and must never break the user's call.
|
|
95
|
+
_log.debug("brooder: capture failed for %s.%s", type(holder).__name__, attr, exc_info=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def wrap(holder: Any, attr: str, capture: CaptureFn, capture_content: bool = False) -> None:
|
|
99
|
+
"""Patch ``holder.attr`` so each call is normalized and recorded.
|
|
100
|
+
|
|
101
|
+
Works for both sync and ``async def`` methods: an async method (e.g. ``AsyncOpenAI``'s
|
|
102
|
+
``chat.completions.create``) is wrapped with a coroutine that awaits the original before
|
|
103
|
+
recording. Idempotent: wrapping an already-wrapped method is a no-op.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
holder: The object owning the method (e.g. ``client.chat.completions``).
|
|
107
|
+
attr: The method name to wrap (e.g. ``"create"``).
|
|
108
|
+
capture: Turns ``(kwargs, response)`` into a :class:`NormalizedCall`.
|
|
109
|
+
capture_content: Forwarded to :func:`record_call`.
|
|
110
|
+
"""
|
|
111
|
+
original = getattr(holder, attr)
|
|
112
|
+
if original in _wrapped:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
wrapper: Callable[..., Any]
|
|
116
|
+
if inspect.iscoroutinefunction(original):
|
|
117
|
+
|
|
118
|
+
@functools.wraps(original)
|
|
119
|
+
async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
120
|
+
response = await original(*args, **kwargs)
|
|
121
|
+
_capture_into_run(holder, attr, capture, kwargs, response, capture_content)
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
wrapper = _async_wrapper
|
|
125
|
+
else:
|
|
126
|
+
|
|
127
|
+
@functools.wraps(original)
|
|
128
|
+
def _sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
129
|
+
response = original(*args, **kwargs)
|
|
130
|
+
_capture_into_run(holder, attr, capture, kwargs, response, capture_content)
|
|
131
|
+
return response
|
|
132
|
+
|
|
133
|
+
wrapper = _sync_wrapper
|
|
134
|
+
|
|
135
|
+
_wrapped.add(wrapper)
|
|
136
|
+
setattr(holder, attr, wrapper)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get(obj: Any, key: str, default: Any = None) -> Any:
|
|
140
|
+
"""Read ``key`` from an object by attribute or a dict by item, tolerating either shape."""
|
|
141
|
+
if obj is None:
|
|
142
|
+
return default
|
|
143
|
+
if isinstance(obj, dict):
|
|
144
|
+
return obj.get(key, default)
|
|
145
|
+
return getattr(obj, key, default)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_json(raw: Any) -> dict[str, Any]:
|
|
149
|
+
"""Coerce a tool-arguments payload (JSON string, dict, or None) into a dict."""
|
|
150
|
+
if isinstance(raw, dict):
|
|
151
|
+
return dict(raw)
|
|
152
|
+
if isinstance(raw, str):
|
|
153
|
+
try:
|
|
154
|
+
parsed = json.loads(raw)
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
return {"_raw": raw}
|
|
157
|
+
return parsed if isinstance(parsed, dict) else {"_value": parsed}
|
|
158
|
+
return as_dict(raw)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def as_dict(value: Any) -> dict[str, Any]:
|
|
162
|
+
"""Best-effort conversion of a mapping-like value into a plain dict."""
|
|
163
|
+
if value is None:
|
|
164
|
+
return {}
|
|
165
|
+
if isinstance(value, dict):
|
|
166
|
+
return dict(value)
|
|
167
|
+
try:
|
|
168
|
+
return dict(value)
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
return {"_value": str(value)}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""AWS Bedrock auto-capture.
|
|
2
|
+
|
|
3
|
+
Wraps the boto3 ``bedrock-runtime`` client's ``converse`` method. The model id comes from the
|
|
4
|
+
call kwargs (``modelId``); content and tool use come from the ``output.message.content`` blocks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from .base import NormalizedCall, ToolRequest, as_dict, get, wrap
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize(kwargs: dict[str, Any], response: Any) -> Optional[NormalizedCall]:
|
|
15
|
+
message = get(get(response, "output"), "message")
|
|
16
|
+
texts: list[str] = []
|
|
17
|
+
tool_calls: list[ToolRequest] = []
|
|
18
|
+
for block in get(message, "content") or []:
|
|
19
|
+
text = get(block, "text")
|
|
20
|
+
if text is not None:
|
|
21
|
+
texts.append(text)
|
|
22
|
+
tool_use = get(block, "toolUse")
|
|
23
|
+
if tool_use:
|
|
24
|
+
tool_calls.append(
|
|
25
|
+
ToolRequest(
|
|
26
|
+
name=get(tool_use, "name") or "",
|
|
27
|
+
arguments=as_dict(get(tool_use, "input")),
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
return NormalizedCall(
|
|
31
|
+
provider="bedrock",
|
|
32
|
+
model=get(kwargs, "modelId"),
|
|
33
|
+
content="".join(texts) or None,
|
|
34
|
+
tool_calls=tool_calls,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def instrument(client: Any, capture_content: bool = False) -> Any:
|
|
39
|
+
"""Instrument a boto3 ``bedrock-runtime`` client's ``converse``.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client: A boto3 client created with ``boto3.client("bedrock-runtime")``.
|
|
43
|
+
capture_content: Also record assistant text, not just tool calls.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The same client, patched in place.
|
|
47
|
+
"""
|
|
48
|
+
wrap(client, "converse", _normalize, capture_content)
|
|
49
|
+
return client
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Claude Agent SDK capture — trajectories from the SDK's hooks.
|
|
2
|
+
|
|
3
|
+
Register Brooder's hooks with the Claude Agent SDK and it records the agent's tool trajectory
|
|
4
|
+
automatically — no manual ``tool_call``:
|
|
5
|
+
|
|
6
|
+
import brooder
|
|
7
|
+
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, ResultMessage
|
|
8
|
+
|
|
9
|
+
options = ClaudeAgentOptions(hooks=brooder.claude_agent_hooks(agent="support-agent"))
|
|
10
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
11
|
+
await client.query(prompt)
|
|
12
|
+
async for message in client.receive_response():
|
|
13
|
+
if isinstance(message, ResultMessage):
|
|
14
|
+
brooder.integrations.claude_agent.record_output(message.session_id, message.result)
|
|
15
|
+
|
|
16
|
+
**How hooks map to steps** (verified against the Python SDK):
|
|
17
|
+
|
|
18
|
+
- ``UserPromptSubmit`` → open a run keyed by ``session_id``; the prompt becomes the case identity.
|
|
19
|
+
- ``PostToolUse`` / ``PostToolUseFailure`` → a ``TOOL`` step (name, input, and the tool response).
|
|
20
|
+
- ``Stop`` → a ``FINAL`` step and save.
|
|
21
|
+
|
|
22
|
+
**SDK constraints this works around.** The Python SDK exposes no ``SessionStart`` / ``SessionEnd``
|
|
23
|
+
hooks (those are settings-file only), so the prompt/stop cycle delimits a run — each user turn in a
|
|
24
|
+
session is one case. And ``Stop`` does not carry the final assistant text (it lives in the
|
|
25
|
+
``ResultMessage`` stream), so the tool *trajectory* — the core diff signal — is always captured, but
|
|
26
|
+
the final *answer* is only recorded if you feed it via :func:`record_output`. Turn counts aren't
|
|
27
|
+
exposed by hooks, so no ``TURN`` steps are fabricated.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import threading
|
|
33
|
+
from typing import Any, Optional
|
|
34
|
+
|
|
35
|
+
from .. import recorder
|
|
36
|
+
from ..errors import BrooderError
|
|
37
|
+
from ..log import get_logger
|
|
38
|
+
from .base import as_dict
|
|
39
|
+
|
|
40
|
+
_log = get_logger()
|
|
41
|
+
|
|
42
|
+
# Final answers recorded out-of-band (hooks don't expose them), keyed by session id.
|
|
43
|
+
_outputs: dict[str, Any] = {}
|
|
44
|
+
_outputs_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def record_output(session_id: str, output: Any) -> None:
|
|
48
|
+
"""Record the agent's final answer for a session so its run gets a populated ``FINAL`` step.
|
|
49
|
+
|
|
50
|
+
The Claude Agent SDK's ``Stop`` hook does not carry the final assistant text — it comes from the
|
|
51
|
+
``ResultMessage`` in the message stream. Call this from your loop (with ``message.result``) to
|
|
52
|
+
make the final output diffable. Optional: the tool trajectory is captured from hooks regardless.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
session_id: The session id from the message / hook payload.
|
|
56
|
+
output: The agent's final answer.
|
|
57
|
+
"""
|
|
58
|
+
with _outputs_lock:
|
|
59
|
+
_outputs[session_id] = output
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pop_output(session_id: str) -> Any:
|
|
63
|
+
"""Take and clear any out-of-band output recorded for ``session_id``."""
|
|
64
|
+
with _outputs_lock:
|
|
65
|
+
return _outputs.pop(session_id, None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _reset() -> None:
|
|
69
|
+
"""Clear out-of-band output state (used by tests)."""
|
|
70
|
+
with _outputs_lock:
|
|
71
|
+
_outputs.clear()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Capture:
|
|
75
|
+
"""Builds the Brooder hook callbacks; each payload carries the ``session_id`` to key on."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, agent: Optional[str]) -> None:
|
|
78
|
+
self._agent = agent
|
|
79
|
+
|
|
80
|
+
def _handle(self, session_id: str) -> recorder.RunHandle:
|
|
81
|
+
"""Get the session's open run, opening one lazily if a hook fired before the prompt."""
|
|
82
|
+
handle = recorder.get_run(session_id)
|
|
83
|
+
if handle is None:
|
|
84
|
+
handle = recorder.open_run(self._agent or "claude-agent", external_id=session_id)
|
|
85
|
+
return handle
|
|
86
|
+
|
|
87
|
+
async def _on_user_prompt(
|
|
88
|
+
self, input_data: dict[str, Any], tool_use_id: Optional[str], context: Any
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
try:
|
|
91
|
+
session_id = input_data.get("session_id")
|
|
92
|
+
if session_id is not None:
|
|
93
|
+
stale = recorder.get_run(session_id)
|
|
94
|
+
if stale is not None:
|
|
95
|
+
stale.finish(_pop_output(session_id)) # prior turn never hit Stop; close it
|
|
96
|
+
handle = recorder.open_run(self._agent or "claude-agent", external_id=session_id)
|
|
97
|
+
prompt = input_data.get("prompt")
|
|
98
|
+
if prompt is not None:
|
|
99
|
+
handle.set_inputs(prompt)
|
|
100
|
+
except Exception: # capture must never break the agent
|
|
101
|
+
_log.debug("brooder: claude-agent UserPromptSubmit capture failed", exc_info=True)
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
async def _on_tool(
|
|
105
|
+
self, input_data: dict[str, Any], tool_use_id: Optional[str], context: Any
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
try:
|
|
108
|
+
session_id = input_data.get("session_id")
|
|
109
|
+
if session_id is not None:
|
|
110
|
+
self._handle(session_id).tool_call(
|
|
111
|
+
input_data.get("tool_name") or "tool",
|
|
112
|
+
as_dict(input_data.get("tool_input")),
|
|
113
|
+
result=input_data.get("tool_response"),
|
|
114
|
+
)
|
|
115
|
+
except Exception:
|
|
116
|
+
_log.debug("brooder: claude-agent PostToolUse capture failed", exc_info=True)
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
async def _on_stop(
|
|
120
|
+
self, input_data: dict[str, Any], tool_use_id: Optional[str], context: Any
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
try:
|
|
123
|
+
session_id = input_data.get("session_id")
|
|
124
|
+
if session_id is not None:
|
|
125
|
+
handle = recorder.get_run(session_id)
|
|
126
|
+
if handle is not None:
|
|
127
|
+
handle.final(_pop_output(session_id)) # Stop = the agent produced an answer
|
|
128
|
+
handle.finish()
|
|
129
|
+
except Exception:
|
|
130
|
+
_log.debug("brooder: claude-agent Stop capture failed", exc_info=True)
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
def callbacks(self) -> dict[str, list[Any]]:
|
|
134
|
+
"""Return ``{event_name: [callback]}`` for the events Brooder captures."""
|
|
135
|
+
return {
|
|
136
|
+
"UserPromptSubmit": [self._on_user_prompt],
|
|
137
|
+
"PostToolUse": [self._on_tool],
|
|
138
|
+
"PostToolUseFailure": [self._on_tool],
|
|
139
|
+
"Stop": [self._on_stop],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def claude_agent_hooks(agent: Optional[str] = None) -> dict[str, list[Any]]:
|
|
144
|
+
"""Build the hooks mapping to pass to ``ClaudeAgentOptions(hooks=...)``.
|
|
145
|
+
|
|
146
|
+
Records the agent's tool trajectory into Brooder runs (see the module docstring for details).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
agent: Logical agent name used to group baselines (defaults to ``"claude-agent"``).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A ``{event_name: [HookMatcher(...)]}`` mapping for ``ClaudeAgentOptions``.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
BrooderError: If the Claude Agent SDK is not installed.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
from claude_agent_sdk import HookMatcher
|
|
159
|
+
except ImportError as exc:
|
|
160
|
+
raise BrooderError(
|
|
161
|
+
"claude_agent_hooks() needs the Claude Agent SDK — `pip install claude-agent-sdk`"
|
|
162
|
+
) from exc
|
|
163
|
+
capture = _Capture(agent)
|
|
164
|
+
return {event: [HookMatcher(hooks=cbs)] for event, cbs in capture.callbacks().items()}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Google Gemini / Vertex AI auto-capture.
|
|
2
|
+
|
|
3
|
+
Wraps a ``GenerativeModel``'s ``generate_content``. The model name is read from the model object
|
|
4
|
+
(``model_name``); content and function calls come from the first candidate's parts. Covers both
|
|
5
|
+
the ``google-generativeai`` (Gemini API) and ``vertexai`` SDKs, which share this response shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from .base import NormalizedCall, ToolRequest, as_dict, get, wrap
|
|
14
|
+
|
|
15
|
+
CaptureFn = Callable[[dict[str, Any], Any], Optional[NormalizedCall]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _normalizer(model_name: Optional[str]) -> CaptureFn:
|
|
19
|
+
def _normalize(_kwargs: dict[str, Any], response: Any) -> Optional[NormalizedCall]:
|
|
20
|
+
candidates = get(response, "candidates") or []
|
|
21
|
+
content = get(candidates[0], "content") if candidates else None
|
|
22
|
+
texts: list[str] = []
|
|
23
|
+
tool_calls: list[ToolRequest] = []
|
|
24
|
+
for part in get(content, "parts") or []:
|
|
25
|
+
text = get(part, "text")
|
|
26
|
+
if text:
|
|
27
|
+
texts.append(text)
|
|
28
|
+
function_call = get(part, "function_call")
|
|
29
|
+
if function_call:
|
|
30
|
+
tool_calls.append(
|
|
31
|
+
ToolRequest(
|
|
32
|
+
name=get(function_call, "name") or "",
|
|
33
|
+
arguments=as_dict(get(function_call, "args")),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return NormalizedCall(
|
|
37
|
+
provider="google",
|
|
38
|
+
model=model_name,
|
|
39
|
+
content="".join(texts) or None,
|
|
40
|
+
tool_calls=tool_calls,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return _normalize
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def instrument(model: Any, capture_content: bool = False) -> Any:
|
|
47
|
+
"""Instrument a Google ``GenerativeModel``'s ``generate_content``.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
model: A ``google.generativeai`` or ``vertexai`` ``GenerativeModel`` instance.
|
|
51
|
+
capture_content: Also record assistant text, not just tool calls.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The same model, patched in place.
|
|
55
|
+
"""
|
|
56
|
+
normalize = _normalizer(get(model, "model_name"))
|
|
57
|
+
wrap(model, "generate_content", normalize, capture_content)
|
|
58
|
+
# Async Gemini/Vertex uses a distinct coroutine method; wrap it too when present.
|
|
59
|
+
if hasattr(model, "generate_content_async"):
|
|
60
|
+
wrap(model, "generate_content_async", normalize, capture_content)
|
|
61
|
+
return model
|