playagent 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.
playagent/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """Public API surface for PlayAgent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def _maybe_print_welcome() -> None:
9
+ """Print a one-time welcome message and initialize DB on first import."""
10
+ import os
11
+
12
+ playagent_dir = Path.home() / ".playagent"
13
+ if playagent_dir.exists() or os.environ.get("PLAYAGENT_NO_WELCOME"):
14
+ return
15
+ try:
16
+ playagent_dir.mkdir(parents=True, exist_ok=True)
17
+ print( # noqa: T201
18
+ f"PlayAgent v0.1.0 — creating local database at {playagent_dir / 'db.sqlite'}"
19
+ )
20
+ from playagent.storage import get_db_path, init_db
21
+
22
+ init_db(get_db_path())
23
+ except Exception: # noqa: BLE001
24
+ pass # Never crash on welcome message
25
+
26
+
27
+ _maybe_print_welcome()
28
+
29
+ from playagent.assertions import ( # noqa: E402
30
+ AssertionFailedError,
31
+ assert_trace,
32
+ contains,
33
+ equals,
34
+ matches,
35
+ starts_with,
36
+ )
37
+ from playagent.classifier import classify_trace, evaluate, score_goal # noqa: E402
38
+ from playagent.core import get_current_session_id, record, trace, validate_environment # noqa: E402
39
+ from playagent.models import ClassificationResult, Session, ToolCall, TraceEvent # noqa: E402
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ "record",
45
+ "trace",
46
+ "get_current_session_id",
47
+ "validate_environment",
48
+ "assert_trace",
49
+ "contains",
50
+ "starts_with",
51
+ "matches",
52
+ "equals",
53
+ "AssertionFailedError",
54
+ "classify_trace",
55
+ "score_goal",
56
+ "evaluate",
57
+ "Session",
58
+ "TraceEvent",
59
+ "ToolCall",
60
+ "ClassificationResult",
61
+ ]
@@ -0,0 +1,293 @@
1
+ """Anthropic drop-in adapters that capture request/response traces into PlayAgent storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ try:
11
+ import anthropic
12
+ except ImportError as exc: # pragma: no cover
13
+ # Scenario: user did `pip install playagent` without the anthropic extra.
14
+ raise ImportError(
15
+ "Anthropic adapter requires the anthropic package. "
16
+ "Install it with: pip install playagent[anthropic]"
17
+ ) from exc
18
+
19
+ from playagent.core import get_current_session_id
20
+ from playagent.models import Session, ToolCall, TraceEvent, generate_id, now_utc
21
+ from playagent.storage import get_turn_count, save_session, save_trace_event, update_session
22
+
23
+
24
+ def _create_standalone_session() -> Session | None:
25
+ """Create and persist a standalone session when no active trace session exists."""
26
+ try:
27
+ started_at = now_utc()
28
+ session = Session(
29
+ id=generate_id("sess"),
30
+ agent_name="standalone",
31
+ status="running",
32
+ started_at=started_at,
33
+ ended_at=None,
34
+ metadata={},
35
+ error=None,
36
+ classification=None,
37
+ )
38
+ save_session(session)
39
+ return session
40
+ except Exception as exc: # noqa: BLE001
41
+ logging.warning("PlayAgent failed to create standalone session: %s", exc)
42
+ return None
43
+
44
+
45
+ def _finalize_standalone_session(
46
+ session: Session | None, status: str, error: str | None = None
47
+ ) -> None:
48
+ """Mark standalone sessions complete so lifecycle mirrors decorator-based sessions."""
49
+ if session is None:
50
+ return
51
+ try:
52
+ session.status = status
53
+ session.error = error
54
+ session.ended_at = now_utc()
55
+ update_session(session)
56
+ except Exception as exc: # noqa: BLE001
57
+ logging.warning("PlayAgent failed to finalize standalone session: %s", exc)
58
+
59
+
60
+ def _resolve_session() -> tuple[str | None, Session | None]:
61
+ """Return active session id, creating standalone session if needed."""
62
+ session_id = get_current_session_id()
63
+ if session_id is not None:
64
+ return session_id, None
65
+
66
+ standalone = _create_standalone_session()
67
+ if standalone is None:
68
+ return None, None
69
+ return standalone.id, standalone
70
+
71
+
72
+ def _extract_response_content(content_blocks: Any) -> str:
73
+ """Extract the first text block from Anthropic content blocks."""
74
+ if not content_blocks:
75
+ return ""
76
+ for block in content_blocks:
77
+ if getattr(block, "type", None) == "text":
78
+ return str(getattr(block, "text", "") or "")
79
+ return ""
80
+
81
+
82
+ def _extract_tool_calls(content_blocks: Any) -> list[ToolCall]:
83
+ """Extract tool_use blocks from Anthropic content blocks."""
84
+ calls: list[ToolCall] = []
85
+ if not content_blocks:
86
+ return calls
87
+
88
+ for block in content_blocks:
89
+ if getattr(block, "type", None) != "tool_use":
90
+ continue
91
+ block_input = getattr(block, "input", None)
92
+ args = block_input if isinstance(block_input, dict) else {}
93
+ calls.append(
94
+ ToolCall(
95
+ name=str(getattr(block, "name", "") or ""),
96
+ arguments=args,
97
+ result=None,
98
+ call_id=getattr(block, "id", None),
99
+ )
100
+ )
101
+ return calls
102
+
103
+
104
+ def _map_finish_reason(stop_reason: str | None) -> str:
105
+ """Map Anthropic stop reasons into unified TraceEvent finish reasons."""
106
+ if stop_reason == "end_turn":
107
+ return "stop"
108
+ if stop_reason == "tool_use":
109
+ return "tool_calls"
110
+ return str(stop_reason or "stop")
111
+
112
+
113
+ def _safe_save_event(event: TraceEvent) -> None:
114
+ """Persist trace event without surfacing instrumentation failures."""
115
+ try:
116
+ save_trace_event(event)
117
+ except Exception as exc: # noqa: BLE001
118
+ logging.warning("PlayAgent failed to save Anthropic trace event %s: %s", event.id, exc)
119
+
120
+
121
+ def _extract_model_and_messages(kwargs: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
122
+ model = str(kwargs.get("model") or "")
123
+ messages_raw = kwargs.get("messages") or []
124
+ if isinstance(messages_raw, list):
125
+ messages = messages_raw
126
+ else:
127
+ messages = []
128
+ return model, messages
129
+
130
+
131
+ def _build_success_event(
132
+ *,
133
+ session_id: str,
134
+ model: str,
135
+ messages: list[dict[str, Any]],
136
+ response: Any,
137
+ latency_ms: int,
138
+ ) -> TraceEvent:
139
+ content = getattr(response, "content", None)
140
+ usage = getattr(response, "usage", None)
141
+
142
+ response_content = _extract_response_content(content)
143
+ tool_calls = _extract_tool_calls(content)
144
+ prompt_tokens = int(getattr(usage, "input_tokens", 0) or 0)
145
+ completion_tokens = int(getattr(usage, "output_tokens", 0) or 0)
146
+ finish_reason = _map_finish_reason(getattr(response, "stop_reason", None))
147
+
148
+ turn = get_turn_count(session_id) + 1
149
+ return TraceEvent(
150
+ id=generate_id("evt"),
151
+ session_id=session_id,
152
+ turn=turn,
153
+ model=model,
154
+ provider="anthropic",
155
+ messages=messages,
156
+ response_content=response_content,
157
+ tool_calls=tool_calls,
158
+ prompt_tokens=prompt_tokens,
159
+ completion_tokens=completion_tokens,
160
+ finish_reason=finish_reason,
161
+ latency_ms=latency_ms,
162
+ error=None,
163
+ created_at=now_utc(),
164
+ )
165
+
166
+
167
+ def _build_error_event(
168
+ *,
169
+ session_id: str,
170
+ model: str,
171
+ messages: list[dict[str, Any]],
172
+ error: Exception,
173
+ latency_ms: int,
174
+ ) -> TraceEvent:
175
+ turn = get_turn_count(session_id) + 1
176
+ return TraceEvent(
177
+ id=generate_id("evt"),
178
+ session_id=session_id,
179
+ turn=turn,
180
+ model=model,
181
+ provider="anthropic",
182
+ messages=messages,
183
+ response_content="",
184
+ tool_calls=[],
185
+ prompt_tokens=0,
186
+ completion_tokens=0,
187
+ finish_reason="error",
188
+ latency_ms=latency_ms,
189
+ error=str(error),
190
+ created_at=now_utc(),
191
+ )
192
+
193
+
194
+ def _instrument_sync_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
195
+ def wrapped_create(*args: Any, **kwargs: Any) -> Any:
196
+ start = time.monotonic()
197
+ model, messages = _extract_model_and_messages(kwargs)
198
+ session_id, standalone_session = _resolve_session()
199
+
200
+ try:
201
+ response = original_create(*args, **kwargs)
202
+ except Exception as error:
203
+ latency_ms = int((time.monotonic() - start) * 1000)
204
+ if session_id is not None:
205
+ error_event = _build_error_event(
206
+ session_id=session_id,
207
+ model=model,
208
+ messages=messages,
209
+ error=error,
210
+ latency_ms=latency_ms,
211
+ )
212
+ _safe_save_event(error_event)
213
+ _finalize_standalone_session(standalone_session, status="failed", error=str(error))
214
+ raise
215
+
216
+ latency_ms = int((time.monotonic() - start) * 1000)
217
+ if session_id is not None:
218
+ try:
219
+ response_model = str(getattr(response, "model", None) or model)
220
+ event = _build_success_event(
221
+ session_id=session_id,
222
+ model=response_model,
223
+ messages=messages,
224
+ response=response,
225
+ latency_ms=latency_ms,
226
+ )
227
+ _safe_save_event(event)
228
+ except Exception as exc: # noqa: BLE001
229
+ logging.warning("PlayAgent failed to instrument Anthropic response: %s", exc)
230
+
231
+ _finalize_standalone_session(standalone_session, status="passed", error=None)
232
+ return response
233
+
234
+ return wrapped_create
235
+
236
+
237
+ def _instrument_async_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
238
+ async def wrapped_create(*args: Any, **kwargs: Any) -> Any:
239
+ start = time.monotonic()
240
+ model, messages = _extract_model_and_messages(kwargs)
241
+ session_id, standalone_session = _resolve_session()
242
+
243
+ try:
244
+ response = await original_create(*args, **kwargs)
245
+ except Exception as error:
246
+ latency_ms = int((time.monotonic() - start) * 1000)
247
+ if session_id is not None:
248
+ error_event = _build_error_event(
249
+ session_id=session_id,
250
+ model=model,
251
+ messages=messages,
252
+ error=error,
253
+ latency_ms=latency_ms,
254
+ )
255
+ _safe_save_event(error_event)
256
+ _finalize_standalone_session(standalone_session, status="failed", error=str(error))
257
+ raise
258
+
259
+ latency_ms = int((time.monotonic() - start) * 1000)
260
+ if session_id is not None:
261
+ try:
262
+ response_model = str(getattr(response, "model", None) or model)
263
+ event = _build_success_event(
264
+ session_id=session_id,
265
+ model=response_model,
266
+ messages=messages,
267
+ response=response,
268
+ latency_ms=latency_ms,
269
+ )
270
+ _safe_save_event(event)
271
+ except Exception as exc: # noqa: BLE001
272
+ logging.warning("PlayAgent failed to instrument Anthropic async response: %s", exc)
273
+
274
+ _finalize_standalone_session(standalone_session, status="passed", error=None)
275
+ return response
276
+
277
+ return wrapped_create
278
+
279
+
280
+ class Anthropic(anthropic.Anthropic):
281
+ """Drop-in Anthropic client with automatic trace persistence."""
282
+
283
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
284
+ super().__init__(*args, **kwargs)
285
+ self.messages.create = _instrument_sync_create(self.messages.create)
286
+
287
+
288
+ class AsyncAnthropic(anthropic.AsyncAnthropic):
289
+ """Async drop-in Anthropic client with automatic trace persistence."""
290
+
291
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
292
+ super().__init__(*args, **kwargs)
293
+ self.messages.create = _instrument_async_create(self.messages.create)
@@ -0,0 +1,282 @@
1
+ """OpenAI drop-in adapters that capture request/response traces into PlayAgent storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ try:
12
+ import openai
13
+ except ImportError as exc: # pragma: no cover
14
+ # Scenario: user did `pip install playagent` without the openai extra.
15
+ raise ImportError(
16
+ "OpenAI adapter requires the openai package. Install it with: pip install playagent[openai]"
17
+ ) from exc
18
+
19
+ from playagent.core import get_current_session_id
20
+ from playagent.models import Session, ToolCall, TraceEvent, generate_id, now_utc
21
+ from playagent.storage import get_turn_count, save_session, save_trace_event, update_session
22
+
23
+
24
+ def _create_standalone_session() -> Session | None:
25
+ """Create and persist a standalone session when no active trace session exists."""
26
+ try:
27
+ started_at = now_utc()
28
+ session = Session(
29
+ id=generate_id("sess"),
30
+ agent_name="standalone",
31
+ status="running",
32
+ started_at=started_at,
33
+ ended_at=None,
34
+ metadata={},
35
+ error=None,
36
+ classification=None,
37
+ )
38
+ save_session(session)
39
+ return session
40
+ except Exception as exc: # noqa: BLE001
41
+ logging.warning("PlayAgent failed to create standalone session: %s", exc)
42
+ return None
43
+
44
+
45
+ def _parse_tool_calls(raw_tool_calls: Any) -> list[ToolCall]:
46
+ """Convert OpenAI tool call payloads into PlayAgent ToolCall models."""
47
+ parsed: list[ToolCall] = []
48
+ if not raw_tool_calls:
49
+ return parsed
50
+
51
+ for call in raw_tool_calls:
52
+ try:
53
+ args = json.loads(call.function.arguments) if call.function.arguments else {}
54
+ except Exception: # noqa: BLE001
55
+ args = {}
56
+ parsed.append(
57
+ ToolCall(
58
+ name=getattr(call.function, "name", ""),
59
+ arguments=args,
60
+ result=None,
61
+ call_id=getattr(call, "id", None),
62
+ )
63
+ )
64
+ return parsed
65
+
66
+
67
+ def _finalize_standalone_session(
68
+ session: Session | None, status: str, error: str | None = None
69
+ ) -> None:
70
+ """Mark standalone sessions complete so lifecycle mirrors decorator-based sessions."""
71
+ if session is None:
72
+ return
73
+ try:
74
+ session.status = status
75
+ session.error = error
76
+ session.ended_at = now_utc()
77
+ update_session(session)
78
+ except Exception as exc: # noqa: BLE001
79
+ logging.warning("PlayAgent failed to finalize standalone session: %s", exc)
80
+
81
+
82
+ def _resolve_session() -> tuple[str | None, Session | None]:
83
+ """Return active session id, creating standalone session if needed."""
84
+ session_id = get_current_session_id()
85
+ if session_id is not None:
86
+ return session_id, None
87
+
88
+ standalone = _create_standalone_session()
89
+ if standalone is None:
90
+ return None, None
91
+ return standalone.id, standalone
92
+
93
+
94
+ def _build_event_from_response(
95
+ *,
96
+ session_id: str,
97
+ model: str,
98
+ messages: list[dict[str, Any]],
99
+ response: Any,
100
+ latency_ms: int,
101
+ ) -> TraceEvent:
102
+ """Build TraceEvent from successful OpenAI API response."""
103
+ choice0 = response.choices[0] if getattr(response, "choices", None) else None
104
+ message = choice0.message if choice0 is not None else None
105
+ usage = getattr(response, "usage", None)
106
+
107
+ response_content = getattr(message, "content", "") if message is not None else ""
108
+ parsed_tool_calls = _parse_tool_calls(getattr(message, "tool_calls", None) if message else None)
109
+ prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
110
+ completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
111
+ finish_reason = getattr(choice0, "finish_reason", "stop") if choice0 is not None else "stop"
112
+
113
+ turn = get_turn_count(session_id) + 1
114
+
115
+ return TraceEvent(
116
+ id=generate_id("evt"),
117
+ session_id=session_id,
118
+ turn=turn,
119
+ model=model,
120
+ provider="openai",
121
+ messages=messages,
122
+ response_content=response_content or "",
123
+ tool_calls=parsed_tool_calls,
124
+ prompt_tokens=prompt_tokens,
125
+ completion_tokens=completion_tokens,
126
+ finish_reason=finish_reason or "stop",
127
+ latency_ms=latency_ms,
128
+ error=None,
129
+ created_at=now_utc(),
130
+ )
131
+
132
+
133
+ def _build_event_from_error(
134
+ *,
135
+ session_id: str,
136
+ model: str,
137
+ messages: list[dict[str, Any]],
138
+ error: Exception,
139
+ latency_ms: int,
140
+ ) -> TraceEvent:
141
+ """Build TraceEvent from failed OpenAI API call."""
142
+ turn = get_turn_count(session_id) + 1
143
+ return TraceEvent(
144
+ id=generate_id("evt"),
145
+ session_id=session_id,
146
+ turn=turn,
147
+ model=model,
148
+ provider="openai",
149
+ messages=messages,
150
+ response_content="",
151
+ tool_calls=[],
152
+ prompt_tokens=0,
153
+ completion_tokens=0,
154
+ finish_reason="error",
155
+ latency_ms=latency_ms,
156
+ error=str(error),
157
+ created_at=now_utc(),
158
+ )
159
+
160
+
161
+ def _safe_save_event(event: TraceEvent) -> None:
162
+ """Persist trace event without surfacing instrumentation failures."""
163
+ try:
164
+ save_trace_event(event)
165
+ except Exception as exc: # noqa: BLE001
166
+ logging.warning("PlayAgent failed to save OpenAI trace event %s: %s", event.id, exc)
167
+
168
+
169
+ def _extract_model_and_messages(kwargs: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
170
+ model = str(kwargs.get("model") or "")
171
+ messages_raw = kwargs.get("messages") or []
172
+ if isinstance(messages_raw, list):
173
+ messages = messages_raw
174
+ else:
175
+ messages = []
176
+ return model, messages
177
+
178
+
179
+ def _instrument_sync_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
180
+ def wrapped_create(*args: Any, **kwargs: Any) -> Any:
181
+ start = time.monotonic()
182
+ model, messages = _extract_model_and_messages(kwargs)
183
+ session_id, standalone_session = _resolve_session()
184
+
185
+ try:
186
+ response = original_create(*args, **kwargs)
187
+ except Exception as error:
188
+ latency_ms = int((time.monotonic() - start) * 1000)
189
+ if session_id is not None:
190
+ error_event = _build_event_from_error(
191
+ session_id=session_id,
192
+ model=model,
193
+ messages=messages,
194
+ error=error,
195
+ latency_ms=latency_ms,
196
+ )
197
+ _safe_save_event(error_event)
198
+ _finalize_standalone_session(standalone_session, status="failed", error=str(error))
199
+ raise
200
+
201
+ latency_ms = int((time.monotonic() - start) * 1000)
202
+ if session_id is not None:
203
+ try:
204
+ response_model = str(getattr(response, "model", None) or model)
205
+ success_event = _build_event_from_response(
206
+ session_id=session_id,
207
+ model=response_model,
208
+ messages=messages,
209
+ response=response,
210
+ latency_ms=latency_ms,
211
+ )
212
+ _safe_save_event(success_event)
213
+ except Exception as exc: # noqa: BLE001
214
+ logging.warning("PlayAgent failed to instrument OpenAI response: %s", exc)
215
+
216
+ _finalize_standalone_session(standalone_session, status="passed", error=None)
217
+ return response
218
+
219
+ return wrapped_create
220
+
221
+
222
+ def _instrument_async_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
223
+ async def wrapped_create(*args: Any, **kwargs: Any) -> Any:
224
+ start = time.monotonic()
225
+ model, messages = _extract_model_and_messages(kwargs)
226
+ session_id, standalone_session = _resolve_session()
227
+
228
+ try:
229
+ response = await original_create(*args, **kwargs)
230
+ except Exception as error:
231
+ latency_ms = int((time.monotonic() - start) * 1000)
232
+ if session_id is not None:
233
+ error_event = _build_event_from_error(
234
+ session_id=session_id,
235
+ model=model,
236
+ messages=messages,
237
+ error=error,
238
+ latency_ms=latency_ms,
239
+ )
240
+ _safe_save_event(error_event)
241
+ _finalize_standalone_session(standalone_session, status="failed", error=str(error))
242
+ raise
243
+
244
+ latency_ms = int((time.monotonic() - start) * 1000)
245
+ if session_id is not None:
246
+ try:
247
+ response_model = str(getattr(response, "model", None) or model)
248
+ success_event = _build_event_from_response(
249
+ session_id=session_id,
250
+ model=response_model,
251
+ messages=messages,
252
+ response=response,
253
+ latency_ms=latency_ms,
254
+ )
255
+ _safe_save_event(success_event)
256
+ except Exception as exc: # noqa: BLE001
257
+ logging.warning("PlayAgent failed to instrument OpenAI async response: %s", exc)
258
+
259
+ _finalize_standalone_session(standalone_session, status="passed", error=None)
260
+ return response
261
+
262
+ return wrapped_create
263
+
264
+
265
+ class OpenAI(openai.OpenAI):
266
+ """Drop-in OpenAI client with automatic trace persistence."""
267
+
268
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
269
+ super().__init__(*args, **kwargs)
270
+ self.chat.completions.create = _instrument_sync_create(self.chat.completions.create)
271
+ if hasattr(self.chat.completions, "acreate"):
272
+ self.chat.completions.acreate = _instrument_async_create(self.chat.completions.acreate)
273
+
274
+
275
+ class AsyncOpenAI(openai.AsyncOpenAI):
276
+ """Async drop-in OpenAI client with automatic trace persistence."""
277
+
278
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
279
+ super().__init__(*args, **kwargs)
280
+ self.chat.completions.create = _instrument_async_create(self.chat.completions.create)
281
+ if hasattr(self.chat.completions, "acreate"):
282
+ self.chat.completions.acreate = _instrument_async_create(self.chat.completions.acreate)