contextforge-eval 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.
- context_forge/__init__.py +95 -0
- context_forge/core/__init__.py +55 -0
- context_forge/core/trace.py +369 -0
- context_forge/core/types.py +121 -0
- context_forge/evaluation.py +267 -0
- context_forge/exceptions.py +56 -0
- context_forge/graders/__init__.py +44 -0
- context_forge/graders/base.py +264 -0
- context_forge/graders/deterministic/__init__.py +11 -0
- context_forge/graders/deterministic/memory_corruption.py +130 -0
- context_forge/graders/hybrid.py +190 -0
- context_forge/graders/judges/__init__.py +11 -0
- context_forge/graders/judges/backends/__init__.py +9 -0
- context_forge/graders/judges/backends/ollama.py +173 -0
- context_forge/graders/judges/base.py +158 -0
- context_forge/graders/judges/memory_hygiene_judge.py +332 -0
- context_forge/graders/judges/models.py +113 -0
- context_forge/harness/__init__.py +43 -0
- context_forge/harness/user_simulator/__init__.py +70 -0
- context_forge/harness/user_simulator/adapters/__init__.py +13 -0
- context_forge/harness/user_simulator/adapters/base.py +67 -0
- context_forge/harness/user_simulator/adapters/crewai.py +100 -0
- context_forge/harness/user_simulator/adapters/langgraph.py +157 -0
- context_forge/harness/user_simulator/adapters/pydanticai.py +105 -0
- context_forge/harness/user_simulator/llm/__init__.py +5 -0
- context_forge/harness/user_simulator/llm/ollama.py +119 -0
- context_forge/harness/user_simulator/models.py +103 -0
- context_forge/harness/user_simulator/persona.py +154 -0
- context_forge/harness/user_simulator/runner.py +342 -0
- context_forge/harness/user_simulator/scenario.py +95 -0
- context_forge/harness/user_simulator/simulator.py +307 -0
- context_forge/instrumentation/__init__.py +23 -0
- context_forge/instrumentation/base.py +307 -0
- context_forge/instrumentation/instrumentors/__init__.py +17 -0
- context_forge/instrumentation/instrumentors/langchain.py +671 -0
- context_forge/instrumentation/instrumentors/langgraph.py +534 -0
- context_forge/instrumentation/tracer.py +588 -0
- context_forge/py.typed +0 -0
- contextforge_eval-0.1.0.dist-info/METADATA +420 -0
- contextforge_eval-0.1.0.dist-info/RECORD +43 -0
- contextforge_eval-0.1.0.dist-info/WHEEL +5 -0
- contextforge_eval-0.1.0.dist-info/licenses/LICENSE +201 -0
- contextforge_eval-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""User simulator implementations."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from langchain_core.messages import BaseMessage, HumanMessage
|
|
6
|
+
|
|
7
|
+
from .llm.ollama import OllamaClient, OllamaConfig
|
|
8
|
+
from .models import ConversationRole, SimulationState
|
|
9
|
+
from .persona import Persona
|
|
10
|
+
from .scenario import ScriptedScenario
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class UserSimulator(Protocol):
|
|
15
|
+
"""Protocol for simulating user behavior in agent conversations.
|
|
16
|
+
|
|
17
|
+
Implementations can be:
|
|
18
|
+
- LLM-based (using Ollama to generate contextual responses)
|
|
19
|
+
- Scripted (returning pre-defined responses)
|
|
20
|
+
- Hybrid (following a script with LLM fallback)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def persona(self) -> Persona:
|
|
25
|
+
"""Get the persona driving this simulator."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
async def generate_response(
|
|
29
|
+
self,
|
|
30
|
+
agent_message: BaseMessage,
|
|
31
|
+
state: SimulationState,
|
|
32
|
+
) -> BaseMessage:
|
|
33
|
+
"""Generate the next user message in response to agent output.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
agent_message: The agent's most recent message
|
|
37
|
+
state: Current simulation state including conversation history
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A HumanMessage representing the simulated user's response
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
async def should_terminate(
|
|
45
|
+
self,
|
|
46
|
+
state: SimulationState,
|
|
47
|
+
) -> tuple[bool, Optional[str]]:
|
|
48
|
+
"""Determine if the conversation should end.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
state: Current simulation state
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Tuple of (should_terminate, reason)
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def reset(self) -> None:
|
|
59
|
+
"""Reset simulator state for a new conversation."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LLMUserSimulator:
|
|
64
|
+
"""User simulator powered by Ollama LLM.
|
|
65
|
+
|
|
66
|
+
Generates contextually appropriate user responses based on
|
|
67
|
+
persona, goals, and conversation history.
|
|
68
|
+
|
|
69
|
+
Example usage:
|
|
70
|
+
persona = Persona(
|
|
71
|
+
persona_id="test_user",
|
|
72
|
+
name="Sarah",
|
|
73
|
+
background="Homeowner with solar panels",
|
|
74
|
+
goals=[Goal(description="Get EV charging advice", ...)],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
simulator = LLMUserSimulator(persona)
|
|
78
|
+
await simulator.initialize()
|
|
79
|
+
|
|
80
|
+
response = await simulator.generate_response(agent_message, state)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
persona: Persona,
|
|
86
|
+
ollama_config: Optional[OllamaConfig] = None,
|
|
87
|
+
check_goals: bool = True,
|
|
88
|
+
):
|
|
89
|
+
"""Initialize the LLM user simulator.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
persona: Persona to simulate
|
|
93
|
+
ollama_config: Configuration for Ollama
|
|
94
|
+
check_goals: Whether to check goal achievement for termination
|
|
95
|
+
"""
|
|
96
|
+
self._persona = persona
|
|
97
|
+
self._ollama_config = ollama_config or OllamaConfig()
|
|
98
|
+
self._check_goals = check_goals
|
|
99
|
+
self._client: Optional[OllamaClient] = None
|
|
100
|
+
self._initialized = False
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def persona(self) -> Persona:
|
|
104
|
+
return self._persona
|
|
105
|
+
|
|
106
|
+
async def initialize(self) -> None:
|
|
107
|
+
"""Initialize the Ollama client."""
|
|
108
|
+
if self._initialized:
|
|
109
|
+
return
|
|
110
|
+
self._client = OllamaClient(self._ollama_config)
|
|
111
|
+
await self._client.__aenter__()
|
|
112
|
+
self._initialized = True
|
|
113
|
+
|
|
114
|
+
async def cleanup(self) -> None:
|
|
115
|
+
"""Clean up resources."""
|
|
116
|
+
if self._client:
|
|
117
|
+
await self._client.__aexit__(None, None, None)
|
|
118
|
+
self._client = None
|
|
119
|
+
self._initialized = False
|
|
120
|
+
|
|
121
|
+
async def generate_response(
|
|
122
|
+
self,
|
|
123
|
+
agent_message: BaseMessage,
|
|
124
|
+
state: SimulationState,
|
|
125
|
+
) -> BaseMessage:
|
|
126
|
+
"""Generate a user response using the LLM."""
|
|
127
|
+
if not self._client or not self._initialized:
|
|
128
|
+
await self.initialize()
|
|
129
|
+
|
|
130
|
+
# Build conversation context
|
|
131
|
+
history = self._format_history(state)
|
|
132
|
+
|
|
133
|
+
prompt = f"""Based on the conversation history below, generate the next message from the user's perspective.
|
|
134
|
+
|
|
135
|
+
Conversation History:
|
|
136
|
+
{history}
|
|
137
|
+
|
|
138
|
+
Agent's last message: {agent_message.content}
|
|
139
|
+
|
|
140
|
+
Generate only the user's response (no labels or prefixes). Stay in character.
|
|
141
|
+
Keep your response focused and concise (1-3 sentences typically)."""
|
|
142
|
+
|
|
143
|
+
system_prompt = self._persona.to_system_prompt()
|
|
144
|
+
response = await self._client.generate(prompt, system=system_prompt)
|
|
145
|
+
|
|
146
|
+
# Clean up response
|
|
147
|
+
cleaned = response.strip()
|
|
148
|
+
# Remove any accidental role prefixes
|
|
149
|
+
for prefix in ["User:", "user:", "Human:", "human:", "Me:", "me:"]:
|
|
150
|
+
if cleaned.startswith(prefix):
|
|
151
|
+
cleaned = cleaned[len(prefix):].strip()
|
|
152
|
+
|
|
153
|
+
return HumanMessage(content=cleaned)
|
|
154
|
+
|
|
155
|
+
def _format_history(self, state: SimulationState) -> str:
|
|
156
|
+
"""Format conversation history for the prompt."""
|
|
157
|
+
lines = []
|
|
158
|
+
# Include last 10 turns for context
|
|
159
|
+
for turn in state.turns[-10:]:
|
|
160
|
+
role = "User" if turn.role == ConversationRole.USER else "Agent"
|
|
161
|
+
lines.append(f"{role}: {turn.message.content}")
|
|
162
|
+
return "\n".join(lines) or "(No history yet)"
|
|
163
|
+
|
|
164
|
+
async def should_terminate(
|
|
165
|
+
self,
|
|
166
|
+
state: SimulationState,
|
|
167
|
+
) -> tuple[bool, Optional[str]]:
|
|
168
|
+
"""Determine if conversation should end."""
|
|
169
|
+
# Check max turns
|
|
170
|
+
if state.current_turn >= state.max_turns:
|
|
171
|
+
return True, "max_turns_reached"
|
|
172
|
+
|
|
173
|
+
# Check goal achievement (use LLM to evaluate)
|
|
174
|
+
if self._check_goals and self._persona.goals:
|
|
175
|
+
achieved = await self._check_goals_achieved(state)
|
|
176
|
+
if achieved:
|
|
177
|
+
return True, "goals_achieved"
|
|
178
|
+
|
|
179
|
+
return False, None
|
|
180
|
+
|
|
181
|
+
async def _check_goals_achieved(self, state: SimulationState) -> bool:
|
|
182
|
+
"""Use LLM to check if goals have been achieved."""
|
|
183
|
+
if not self._client or not self._initialized:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
pending_goals = self._persona.get_pending_goals()
|
|
187
|
+
if not pending_goals:
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
goals_str = "\n".join(
|
|
191
|
+
f"- {g.description}: {g.success_criteria}"
|
|
192
|
+
for g in pending_goals
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
history = self._format_history(state)
|
|
196
|
+
|
|
197
|
+
prompt = f"""Based on this conversation, have the user's goals been achieved?
|
|
198
|
+
|
|
199
|
+
Goals:
|
|
200
|
+
{goals_str}
|
|
201
|
+
|
|
202
|
+
Conversation:
|
|
203
|
+
{history}
|
|
204
|
+
|
|
205
|
+
Answer with ONLY 'yes' or 'no'."""
|
|
206
|
+
|
|
207
|
+
response = await self._client.generate(prompt)
|
|
208
|
+
return response.strip().lower() == "yes"
|
|
209
|
+
|
|
210
|
+
def reset(self) -> None:
|
|
211
|
+
"""Reset persona goal states."""
|
|
212
|
+
self._persona.reset_goals()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ScriptedUserSimulator:
|
|
216
|
+
"""User simulator that follows a pre-defined script.
|
|
217
|
+
|
|
218
|
+
Falls back to LLM generation if script is exhausted
|
|
219
|
+
and fallback mode is 'generative'.
|
|
220
|
+
|
|
221
|
+
Example usage:
|
|
222
|
+
scenario = ScriptedScenario(
|
|
223
|
+
scenario_id="test",
|
|
224
|
+
name="Test scenario",
|
|
225
|
+
persona=persona,
|
|
226
|
+
turns=[
|
|
227
|
+
ScriptedTurn(turn_number=0, user_message="Hello"),
|
|
228
|
+
ScriptedTurn(turn_number=1, user_message="What time should I charge?"),
|
|
229
|
+
],
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
simulator = ScriptedUserSimulator(scenario)
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
scenario: ScriptedScenario,
|
|
238
|
+
llm_fallback: Optional[LLMUserSimulator] = None,
|
|
239
|
+
):
|
|
240
|
+
"""Initialize scripted user simulator.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
scenario: Scripted scenario with predefined turns
|
|
244
|
+
llm_fallback: Optional LLM simulator for fallback generation
|
|
245
|
+
"""
|
|
246
|
+
self._scenario = scenario
|
|
247
|
+
self._llm_fallback = llm_fallback
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def persona(self) -> Persona:
|
|
251
|
+
return self._scenario.persona
|
|
252
|
+
|
|
253
|
+
async def initialize(self) -> None:
|
|
254
|
+
"""Initialize fallback simulator if present."""
|
|
255
|
+
if self._llm_fallback:
|
|
256
|
+
await self._llm_fallback.initialize()
|
|
257
|
+
|
|
258
|
+
async def cleanup(self) -> None:
|
|
259
|
+
"""Clean up fallback simulator if present."""
|
|
260
|
+
if self._llm_fallback:
|
|
261
|
+
await self._llm_fallback.cleanup()
|
|
262
|
+
|
|
263
|
+
async def generate_response(
|
|
264
|
+
self,
|
|
265
|
+
agent_message: BaseMessage,
|
|
266
|
+
state: SimulationState,
|
|
267
|
+
) -> BaseMessage:
|
|
268
|
+
"""Return scripted response or fall back to LLM."""
|
|
269
|
+
scripted = self._scenario.get_turn_message(state.current_turn)
|
|
270
|
+
|
|
271
|
+
if scripted:
|
|
272
|
+
return HumanMessage(content=scripted)
|
|
273
|
+
|
|
274
|
+
# Script exhausted
|
|
275
|
+
if self._scenario.fallback == "terminate":
|
|
276
|
+
raise StopIteration("Script exhausted")
|
|
277
|
+
elif self._scenario.fallback == "loop":
|
|
278
|
+
# Restart from beginning
|
|
279
|
+
if self._scenario.turns:
|
|
280
|
+
turn_in_script = state.current_turn % len(self._scenario.turns)
|
|
281
|
+
scripted = self._scenario.turns[turn_in_script].user_message
|
|
282
|
+
return HumanMessage(content=scripted)
|
|
283
|
+
raise StopIteration("No turns in script")
|
|
284
|
+
elif self._scenario.fallback == "generative" and self._llm_fallback:
|
|
285
|
+
return await self._llm_fallback.generate_response(agent_message, state)
|
|
286
|
+
|
|
287
|
+
raise ValueError(f"Invalid fallback mode: {self._scenario.fallback}")
|
|
288
|
+
|
|
289
|
+
async def should_terminate(
|
|
290
|
+
self,
|
|
291
|
+
state: SimulationState,
|
|
292
|
+
) -> tuple[bool, Optional[str]]:
|
|
293
|
+
"""Check termination conditions."""
|
|
294
|
+
if state.current_turn >= self._scenario.max_turns:
|
|
295
|
+
return True, "max_turns_reached"
|
|
296
|
+
|
|
297
|
+
# Check if script is exhausted in terminate mode
|
|
298
|
+
if self._scenario.fallback == "terminate":
|
|
299
|
+
scripted = self._scenario.get_turn_message(state.current_turn)
|
|
300
|
+
if scripted is None:
|
|
301
|
+
return True, "script_exhausted"
|
|
302
|
+
|
|
303
|
+
return False, None
|
|
304
|
+
|
|
305
|
+
def reset(self) -> None:
|
|
306
|
+
"""Reset state."""
|
|
307
|
+
self._scenario.persona.reset_goals()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Instrumentation module for ContextForge.
|
|
2
|
+
|
|
3
|
+
This module provides multiple levels of trace capture:
|
|
4
|
+
- Level 2: Auto-instrumentation via Instrumentor().instrument()
|
|
5
|
+
- Level 3: Callback handlers for per-call control
|
|
6
|
+
- Level 4: Explicit Tracer API for custom agents
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from context_forge.instrumentation.base import (
|
|
10
|
+
BaseInstrumentor,
|
|
11
|
+
RedactionConfig,
|
|
12
|
+
)
|
|
13
|
+
from context_forge.instrumentation.instrumentors.langchain import LangChainInstrumentor
|
|
14
|
+
from context_forge.instrumentation.instrumentors.langgraph import LangGraphInstrumentor
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Base classes
|
|
18
|
+
"BaseInstrumentor",
|
|
19
|
+
"RedactionConfig",
|
|
20
|
+
# Framework instrumentors
|
|
21
|
+
"LangChainInstrumentor",
|
|
22
|
+
"LangGraphInstrumentor",
|
|
23
|
+
]
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Base instrumentation classes for ContextForge.
|
|
2
|
+
|
|
3
|
+
This module implements:
|
|
4
|
+
- T033: RedactionConfig model
|
|
5
|
+
- T034: BaseInstrumentor abstract class
|
|
6
|
+
- T035: instrument() and uninstrument() methods
|
|
7
|
+
- T036: get_traces() method
|
|
8
|
+
- T037: Context manager protocol
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import uuid
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional, Pattern
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
19
|
+
|
|
20
|
+
from context_forge.core.trace import TraceRun
|
|
21
|
+
from context_forge.core.types import AgentInfo
|
|
22
|
+
from context_forge.exceptions import (
|
|
23
|
+
InstrumentorAlreadyActiveError,
|
|
24
|
+
InstrumentorNotActiveError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedactionConfig(BaseModel):
|
|
29
|
+
"""Configuration for PII/secret redaction in traces.
|
|
30
|
+
|
|
31
|
+
Allows users to specify patterns and field names that should
|
|
32
|
+
be redacted from trace output.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
patterns: Regex patterns to match and redact
|
|
36
|
+
field_names: Field names to always redact
|
|
37
|
+
replacement: String to replace redacted content
|
|
38
|
+
enabled: Whether redaction is active
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
42
|
+
|
|
43
|
+
patterns: list[Pattern[str]] = Field(default_factory=list)
|
|
44
|
+
field_names: list[str] = Field(
|
|
45
|
+
default_factory=lambda: ["password", "api_key", "secret", "token", "authorization"]
|
|
46
|
+
)
|
|
47
|
+
replacement: str = "[REDACTED]"
|
|
48
|
+
enabled: bool = True
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def default(cls) -> "RedactionConfig":
|
|
52
|
+
"""Create default redaction config with common patterns."""
|
|
53
|
+
return cls(
|
|
54
|
+
patterns=[
|
|
55
|
+
re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"), # Email
|
|
56
|
+
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), # SSN
|
|
57
|
+
re.compile(r"\b\d{16}\b"), # Credit card (simple)
|
|
58
|
+
],
|
|
59
|
+
field_names=["password", "api_key", "secret", "token", "authorization", "bearer"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def redact(self, value: str) -> str:
|
|
63
|
+
"""Apply redaction to a string value.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
value: String to potentially redact
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Redacted string if patterns match, original otherwise
|
|
70
|
+
"""
|
|
71
|
+
if not self.enabled or not value:
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
result = value
|
|
75
|
+
for pattern in self.patterns:
|
|
76
|
+
result = pattern.sub(self.replacement, result)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def should_redact_field(self, field_name: str) -> bool:
|
|
80
|
+
"""Check if a field name should be redacted.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
field_name: Name of the field to check
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if the field should be redacted
|
|
87
|
+
"""
|
|
88
|
+
if not self.enabled:
|
|
89
|
+
return False
|
|
90
|
+
field_lower = field_name.lower()
|
|
91
|
+
return any(name.lower() in field_lower for name in self.field_names)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BaseInstrumentor(ABC):
|
|
95
|
+
"""Abstract base class for framework instrumentors.
|
|
96
|
+
|
|
97
|
+
Provides the common interface for auto-instrumentation of
|
|
98
|
+
agent frameworks. Subclasses implement framework-specific
|
|
99
|
+
hooks.
|
|
100
|
+
|
|
101
|
+
Usage:
|
|
102
|
+
instrumentor = LangChainInstrumentor()
|
|
103
|
+
instrumentor.instrument()
|
|
104
|
+
# ... run agent code ...
|
|
105
|
+
traces = instrumentor.get_traces()
|
|
106
|
+
instrumentor.uninstrument()
|
|
107
|
+
|
|
108
|
+
Or with context manager:
|
|
109
|
+
with LangChainInstrumentor() as instrumentor:
|
|
110
|
+
# ... run agent code ...
|
|
111
|
+
traces = instrumentor.get_traces()
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
agent_name: str = "default",
|
|
117
|
+
agent_version: Optional[str] = None,
|
|
118
|
+
output_path: Optional[str | Path] = None,
|
|
119
|
+
redaction_config: Optional[RedactionConfig] = None,
|
|
120
|
+
):
|
|
121
|
+
"""Initialize the instrumentor.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
agent_name: Name to assign to traced agent
|
|
125
|
+
agent_version: Version string for the agent
|
|
126
|
+
output_path: Directory to save traces (optional)
|
|
127
|
+
redaction_config: PII redaction configuration
|
|
128
|
+
"""
|
|
129
|
+
self._agent_name = agent_name
|
|
130
|
+
self._agent_version = agent_version
|
|
131
|
+
self._output_path = Path(output_path) if output_path else None
|
|
132
|
+
self._redaction_config = redaction_config or RedactionConfig()
|
|
133
|
+
self._is_active = False
|
|
134
|
+
self._traces: list[TraceRun] = []
|
|
135
|
+
self._current_trace: Optional[TraceRun] = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def is_active(self) -> bool:
|
|
139
|
+
"""Whether instrumentation is currently active."""
|
|
140
|
+
return self._is_active
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def framework(self) -> str:
|
|
145
|
+
"""Return the framework name (e.g., 'langchain', 'crewai')."""
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
@abstractmethod
|
|
150
|
+
def framework_version(self) -> Optional[str]:
|
|
151
|
+
"""Return the framework version if available."""
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def instrument(self) -> "BaseInstrumentor":
|
|
155
|
+
"""Activate instrumentation.
|
|
156
|
+
|
|
157
|
+
Installs hooks into the framework to capture trace events.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Self for method chaining
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
InstrumentorAlreadyActiveError: If already instrumented
|
|
164
|
+
"""
|
|
165
|
+
if self._is_active:
|
|
166
|
+
raise InstrumentorAlreadyActiveError(
|
|
167
|
+
f"{self.__class__.__name__} is already active"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
self._install_hooks()
|
|
171
|
+
self._is_active = True
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def uninstrument(self) -> None:
|
|
175
|
+
"""Deactivate instrumentation.
|
|
176
|
+
|
|
177
|
+
Removes hooks and finalizes any active traces.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
InstrumentorNotActiveError: If not currently instrumented
|
|
181
|
+
"""
|
|
182
|
+
if not self._is_active:
|
|
183
|
+
raise InstrumentorNotActiveError(
|
|
184
|
+
f"{self.__class__.__name__} is not active"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
self._finalize_current_trace()
|
|
188
|
+
self._remove_hooks()
|
|
189
|
+
self._is_active = False
|
|
190
|
+
|
|
191
|
+
@abstractmethod
|
|
192
|
+
def _install_hooks(self) -> None:
|
|
193
|
+
"""Install framework-specific hooks.
|
|
194
|
+
|
|
195
|
+
Subclasses must implement this to add callbacks/patches
|
|
196
|
+
to the target framework.
|
|
197
|
+
"""
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
@abstractmethod
|
|
201
|
+
def _remove_hooks(self) -> None:
|
|
202
|
+
"""Remove framework-specific hooks.
|
|
203
|
+
|
|
204
|
+
Subclasses must implement this to clean up any installed
|
|
205
|
+
callbacks/patches.
|
|
206
|
+
"""
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
def get_traces(self) -> list[TraceRun]:
|
|
210
|
+
"""Get all captured traces.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
List of TraceRun objects captured during instrumentation
|
|
214
|
+
"""
|
|
215
|
+
# Include current trace if active
|
|
216
|
+
traces = list(self._traces)
|
|
217
|
+
if self._current_trace is not None:
|
|
218
|
+
traces.append(self._current_trace)
|
|
219
|
+
return traces
|
|
220
|
+
|
|
221
|
+
def clear_traces(self) -> None:
|
|
222
|
+
"""Clear all captured traces."""
|
|
223
|
+
self._traces.clear()
|
|
224
|
+
self._current_trace = None
|
|
225
|
+
|
|
226
|
+
def _start_trace(self, task_description: Optional[str] = None) -> TraceRun:
|
|
227
|
+
"""Start a new trace.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
task_description: Optional description of the task
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The new TraceRun object
|
|
234
|
+
"""
|
|
235
|
+
self._finalize_current_trace()
|
|
236
|
+
|
|
237
|
+
from context_forge.core.types import TaskInfo
|
|
238
|
+
|
|
239
|
+
agent_info = AgentInfo(
|
|
240
|
+
name=self._agent_name,
|
|
241
|
+
version=self._agent_version,
|
|
242
|
+
framework=self.framework,
|
|
243
|
+
framework_version=self.framework_version,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
task_info = None
|
|
247
|
+
if task_description:
|
|
248
|
+
task_info = TaskInfo(description=task_description)
|
|
249
|
+
|
|
250
|
+
self._current_trace = TraceRun(
|
|
251
|
+
run_id=str(uuid.uuid4()),
|
|
252
|
+
started_at=datetime.now(timezone.utc),
|
|
253
|
+
agent_info=agent_info,
|
|
254
|
+
task_info=task_info,
|
|
255
|
+
)
|
|
256
|
+
return self._current_trace
|
|
257
|
+
|
|
258
|
+
def _finalize_current_trace(self) -> None:
|
|
259
|
+
"""Finalize the current trace and add to completed traces."""
|
|
260
|
+
if self._current_trace is not None:
|
|
261
|
+
self._current_trace.ended_at = datetime.now(timezone.utc)
|
|
262
|
+
self._traces.append(self._current_trace)
|
|
263
|
+
|
|
264
|
+
# Save to file if output path configured
|
|
265
|
+
if self._output_path:
|
|
266
|
+
self._save_trace(self._current_trace)
|
|
267
|
+
|
|
268
|
+
self._current_trace = None
|
|
269
|
+
|
|
270
|
+
def _save_trace(self, trace: TraceRun) -> Path:
|
|
271
|
+
"""Save a trace to the output directory.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
trace: The trace to save
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Path to the saved file
|
|
278
|
+
"""
|
|
279
|
+
if self._output_path is None:
|
|
280
|
+
raise ValueError("No output path configured")
|
|
281
|
+
|
|
282
|
+
self._output_path.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
filename = f"trace-{trace.run_id}.json"
|
|
284
|
+
filepath = self._output_path / filename
|
|
285
|
+
|
|
286
|
+
with open(filepath, "w") as f:
|
|
287
|
+
f.write(trace.to_json(indent=2))
|
|
288
|
+
|
|
289
|
+
return filepath
|
|
290
|
+
|
|
291
|
+
def _get_current_trace(self) -> TraceRun:
|
|
292
|
+
"""Get or create current trace.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
The current active TraceRun
|
|
296
|
+
"""
|
|
297
|
+
if self._current_trace is None:
|
|
298
|
+
self._start_trace()
|
|
299
|
+
return self._current_trace
|
|
300
|
+
|
|
301
|
+
def __enter__(self) -> "BaseInstrumentor":
|
|
302
|
+
"""Enter context manager, activating instrumentation."""
|
|
303
|
+
return self.instrument()
|
|
304
|
+
|
|
305
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
306
|
+
"""Exit context manager, deactivating instrumentation."""
|
|
307
|
+
self.uninstrument()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Framework-specific instrumentors for ContextForge.
|
|
2
|
+
|
|
3
|
+
Each instrumentor provides one-line auto-instrumentation for
|
|
4
|
+
a specific agent framework.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from context_forge.instrumentation.instrumentors.langchain import (
|
|
8
|
+
ContextForgeCallbackHandler,
|
|
9
|
+
LangChainInstrumentor,
|
|
10
|
+
)
|
|
11
|
+
from context_forge.instrumentation.instrumentors.langgraph import LangGraphInstrumentor
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"LangChainInstrumentor",
|
|
15
|
+
"LangGraphInstrumentor",
|
|
16
|
+
"ContextForgeCallbackHandler",
|
|
17
|
+
]
|