checkagent 0.0.1a1__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.
Files changed (82) hide show
  1. checkagent/__init__.py +94 -0
  2. checkagent/adapters/__init__.py +32 -0
  3. checkagent/adapters/anthropic.py +210 -0
  4. checkagent/adapters/crewai.py +189 -0
  5. checkagent/adapters/generic.py +109 -0
  6. checkagent/adapters/langchain.py +241 -0
  7. checkagent/adapters/openai_agents.py +221 -0
  8. checkagent/adapters/pydantic_ai.py +188 -0
  9. checkagent/ci/__init__.py +40 -0
  10. checkagent/ci/entrypoint.py +100 -0
  11. checkagent/ci/junit_xml.py +310 -0
  12. checkagent/ci/quality_gate.py +169 -0
  13. checkagent/ci/reporter.py +192 -0
  14. checkagent/cli/__init__.py +28 -0
  15. checkagent/cli/demo.py +185 -0
  16. checkagent/cli/import_trace.py +200 -0
  17. checkagent/cli/init.py +150 -0
  18. checkagent/cli/migrate.py +66 -0
  19. checkagent/cli/run.py +42 -0
  20. checkagent/conversation/__init__.py +5 -0
  21. checkagent/conversation/session.py +227 -0
  22. checkagent/core/__init__.py +37 -0
  23. checkagent/core/adapter.py +26 -0
  24. checkagent/core/config.py +237 -0
  25. checkagent/core/cost.py +338 -0
  26. checkagent/core/plugin.py +266 -0
  27. checkagent/core/types.py +145 -0
  28. checkagent/datasets/__init__.py +12 -0
  29. checkagent/datasets/loader.py +127 -0
  30. checkagent/datasets/schema.py +75 -0
  31. checkagent/eval/__init__.py +44 -0
  32. checkagent/eval/aggregate.py +265 -0
  33. checkagent/eval/assertions.py +343 -0
  34. checkagent/eval/evaluator.py +123 -0
  35. checkagent/eval/metrics.py +237 -0
  36. checkagent/judge/__init__.py +34 -0
  37. checkagent/judge/consensus.py +127 -0
  38. checkagent/judge/judge.py +281 -0
  39. checkagent/judge/types.py +157 -0
  40. checkagent/judge/verdict.py +84 -0
  41. checkagent/mock/__init__.py +73 -0
  42. checkagent/mock/fault.py +605 -0
  43. checkagent/mock/llm.py +582 -0
  44. checkagent/mock/mcp.py +342 -0
  45. checkagent/mock/tool.py +487 -0
  46. checkagent/multiagent/__init__.py +25 -0
  47. checkagent/multiagent/credit.py +229 -0
  48. checkagent/multiagent/trace.py +235 -0
  49. checkagent/replay/__init__.py +44 -0
  50. checkagent/replay/cassette.py +196 -0
  51. checkagent/replay/engine.py +167 -0
  52. checkagent/replay/migration.py +211 -0
  53. checkagent/replay/recorder.py +161 -0
  54. checkagent/safety/__init__.py +70 -0
  55. checkagent/safety/compliance.py +438 -0
  56. checkagent/safety/conversation_scanner.py +138 -0
  57. checkagent/safety/evaluator.py +71 -0
  58. checkagent/safety/injection.py +194 -0
  59. checkagent/safety/pii.py +133 -0
  60. checkagent/safety/probes/__init__.py +26 -0
  61. checkagent/safety/probes/base.py +98 -0
  62. checkagent/safety/probes/injection.py +431 -0
  63. checkagent/safety/probes/jailbreak.py +219 -0
  64. checkagent/safety/probes/pii.py +126 -0
  65. checkagent/safety/probes/scope.py +101 -0
  66. checkagent/safety/refusal.py +141 -0
  67. checkagent/safety/system_prompt.py +121 -0
  68. checkagent/safety/taxonomy.py +65 -0
  69. checkagent/safety/tool_boundary.py +180 -0
  70. checkagent/streaming/__init__.py +7 -0
  71. checkagent/streaming/collector.py +107 -0
  72. checkagent/trace_import/__init__.py +22 -0
  73. checkagent/trace_import/base.py +39 -0
  74. checkagent/trace_import/json_importer.py +214 -0
  75. checkagent/trace_import/otel_importer.py +239 -0
  76. checkagent/trace_import/pii.py +123 -0
  77. checkagent/trace_import/testcase_gen.py +151 -0
  78. checkagent-0.0.1a1.dist-info/METADATA +178 -0
  79. checkagent-0.0.1a1.dist-info/RECORD +82 -0
  80. checkagent-0.0.1a1.dist-info/WHEEL +4 -0
  81. checkagent-0.0.1a1.dist-info/entry_points.txt +5 -0
  82. checkagent-0.0.1a1.dist-info/licenses/LICENSE +190 -0
checkagent/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """CheckAgent — The open-source testing framework for AI agents."""
2
+
3
+ __version__ = "0.0.1a1"
4
+
5
+ from checkagent.adapters.generic import GenericAdapter, wrap
6
+ from checkagent.conversation.session import Conversation, Turn
7
+ from checkagent.core.config import CheckAgentConfig, load_config
8
+ from checkagent.core.cost import (
9
+ BudgetExceededError,
10
+ CostBreakdown,
11
+ CostReport,
12
+ CostTracker,
13
+ calculate_run_cost,
14
+ )
15
+ from checkagent.core.types import (
16
+ AgentInput,
17
+ AgentRun,
18
+ Score,
19
+ Step,
20
+ StreamEvent,
21
+ StreamEventType,
22
+ ToolCall,
23
+ )
24
+ from checkagent.eval.assertions import (
25
+ StructuredAssertionError,
26
+ assert_json_schema,
27
+ assert_output_matches,
28
+ assert_output_schema,
29
+ assert_tool_called,
30
+ )
31
+ from checkagent.mock.fault import FaultInjector
32
+ from checkagent.mock.llm import MatchMode, MockLLM
33
+ from checkagent.mock.mcp import MockMCPServer
34
+ from checkagent.mock.tool import MockTool, literal
35
+ from checkagent.streaming.collector import StreamCollector
36
+
37
+ _LAZY_ADAPTER_IMPORTS: dict[str, tuple[str, str]] = {
38
+ "AnthropicAdapter": ("checkagent.adapters.anthropic", "AnthropicAdapter"),
39
+ "CrewAIAdapter": ("checkagent.adapters.crewai", "CrewAIAdapter"),
40
+ "LangChainAdapter": ("checkagent.adapters.langchain", "LangChainAdapter"),
41
+ "OpenAIAgentsAdapter": ("checkagent.adapters.openai_agents", "OpenAIAgentsAdapter"),
42
+ "PydanticAIAdapter": ("checkagent.adapters.pydantic_ai", "PydanticAIAdapter"),
43
+ }
44
+
45
+
46
+ def __getattr__(name: str):
47
+ """Lazy-load framework adapters to avoid import errors for optional deps."""
48
+ if name in _LAZY_ADAPTER_IMPORTS:
49
+ import importlib
50
+
51
+ module_path, attr = _LAZY_ADAPTER_IMPORTS[name]
52
+ mod = importlib.import_module(module_path)
53
+ return getattr(mod, attr)
54
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
55
+
56
+
57
+ __all__ = [
58
+ "__version__",
59
+ "AgentInput",
60
+ "AgentRun",
61
+ "AnthropicAdapter",
62
+ "BudgetExceededError",
63
+ "calculate_run_cost",
64
+ "CheckAgentConfig",
65
+ "CostBreakdown",
66
+ "CostReport",
67
+ "CostTracker",
68
+ "Conversation",
69
+ "CrewAIAdapter",
70
+ "FaultInjector",
71
+ "GenericAdapter",
72
+ "LangChainAdapter",
73
+ "MatchMode",
74
+ "MockLLM",
75
+ "MockMCPServer",
76
+ "MockTool",
77
+ "OpenAIAgentsAdapter",
78
+ "PydanticAIAdapter",
79
+ "Score",
80
+ "Step",
81
+ "StreamCollector",
82
+ "StreamEvent",
83
+ "StreamEventType",
84
+ "StructuredAssertionError",
85
+ "ToolCall",
86
+ "Turn",
87
+ "assert_json_schema",
88
+ "assert_output_matches",
89
+ "assert_output_schema",
90
+ "assert_tool_called",
91
+ "literal",
92
+ "load_config",
93
+ "wrap",
94
+ ]
@@ -0,0 +1,32 @@
1
+ """Adapters — framework-specific wrappers conforming to AgentAdapter."""
2
+
3
+ from checkagent.adapters.generic import GenericAdapter, wrap
4
+
5
+ __all__ = [
6
+ "AnthropicAdapter",
7
+ "CrewAIAdapter",
8
+ "GenericAdapter",
9
+ "LangChainAdapter",
10
+ "OpenAIAgentsAdapter",
11
+ "PydanticAIAdapter",
12
+ "wrap",
13
+ ]
14
+
15
+ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
16
+ "LangChainAdapter": ("checkagent.adapters.langchain", "LangChainAdapter"),
17
+ "OpenAIAgentsAdapter": ("checkagent.adapters.openai_agents", "OpenAIAgentsAdapter"),
18
+ "CrewAIAdapter": ("checkagent.adapters.crewai", "CrewAIAdapter"),
19
+ "PydanticAIAdapter": ("checkagent.adapters.pydantic_ai", "PydanticAIAdapter"),
20
+ "AnthropicAdapter": ("checkagent.adapters.anthropic", "AnthropicAdapter"),
21
+ }
22
+
23
+
24
+ def __getattr__(name: str):
25
+ """Lazy-load framework adapters to avoid import errors for optional deps."""
26
+ if name in _LAZY_IMPORTS:
27
+ module_path, attr = _LAZY_IMPORTS[name]
28
+ import importlib
29
+
30
+ mod = importlib.import_module(module_path)
31
+ return getattr(mod, attr)
32
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,210 @@
1
+ """Anthropic Claude SDK adapter — wraps AsyncAnthropic as AgentAdapter.
2
+
3
+ Requires ``anthropic`` to be installed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import functools
10
+ import inspect
11
+ import time
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any
14
+
15
+ from checkagent.core.types import (
16
+ AgentInput,
17
+ AgentRun,
18
+ Step,
19
+ StreamEvent,
20
+ StreamEventType,
21
+ ToolCall,
22
+ )
23
+
24
+
25
+ def _ensure_anthropic() -> None:
26
+ """Raise a clear error if anthropic is not installed."""
27
+ try:
28
+ import anthropic # noqa: F401
29
+ except ImportError:
30
+ raise ImportError(
31
+ "AnthropicAdapter requires anthropic. "
32
+ "Install it with: pip install anthropic"
33
+ ) from None
34
+
35
+
36
+ def _extract_text(message: Any) -> str:
37
+ """Extract text content from an Anthropic Message."""
38
+ content = getattr(message, "content", [])
39
+ texts: list[str] = []
40
+ for block in content:
41
+ if isinstance(block, dict):
42
+ if block.get("type") == "text":
43
+ texts.append(block["text"])
44
+ elif getattr(block, "type", None) == "text" and hasattr(block, "text"):
45
+ texts.append(block.text)
46
+ return "\n".join(texts) if texts else ""
47
+
48
+
49
+ def _extract_tool_calls(message: Any) -> list[ToolCall]:
50
+ """Extract tool use blocks from an Anthropic Message."""
51
+ calls: list[ToolCall] = []
52
+ content = getattr(message, "content", [])
53
+ for block in content:
54
+ if isinstance(block, dict):
55
+ if block.get("type") == "tool_use":
56
+ calls.append(ToolCall(
57
+ name=block.get("name", "unknown"),
58
+ arguments=block.get("input", {}),
59
+ ))
60
+ elif getattr(block, "type", None) == "tool_use":
61
+ calls.append(ToolCall(
62
+ name=getattr(block, "name", "unknown"),
63
+ arguments=getattr(block, "input", {}),
64
+ ))
65
+ return calls
66
+
67
+
68
+ def _extract_token_usage(message: Any) -> dict[str, int | None]:
69
+ """Extract token usage from an Anthropic Message."""
70
+ usage_info: dict[str, int | None] = {
71
+ "prompt_tokens": None,
72
+ "completion_tokens": None,
73
+ }
74
+ usage = getattr(message, "usage", None)
75
+ if usage:
76
+ usage_info["prompt_tokens"] = getattr(usage, "input_tokens", None)
77
+ usage_info["completion_tokens"] = getattr(usage, "output_tokens", None)
78
+ return usage_info
79
+
80
+
81
+ class AnthropicAdapter:
82
+ """Wraps an Anthropic client as an AgentAdapter."""
83
+
84
+ def __init__(
85
+ self,
86
+ client: Any,
87
+ *,
88
+ model: str = "claude-sonnet-4-20250514",
89
+ system: str | None = None,
90
+ max_tokens: int = 1024,
91
+ ) -> None:
92
+ _ensure_anthropic()
93
+ self._client = client
94
+ self._model = model
95
+ self._system = system
96
+ self._max_tokens = max_tokens
97
+
98
+ async def run(self, input: AgentInput | str) -> AgentRun:
99
+ """Send a message and return an AgentRun trace."""
100
+ if isinstance(input, str):
101
+ input = AgentInput(query=input)
102
+
103
+ kwargs: dict[str, Any] = {
104
+ "model": self._model,
105
+ "max_tokens": self._max_tokens,
106
+ "messages": [{"role": "user", "content": input.query}],
107
+ }
108
+ if self._system:
109
+ kwargs["system"] = self._system
110
+
111
+ start = time.perf_counter()
112
+ try:
113
+ if hasattr(self._client, "messages") and hasattr(
114
+ self._client.messages, "create"
115
+ ):
116
+ create_fn = self._client.messages.create
117
+ else:
118
+ create_fn = self._client.create
119
+
120
+ if inspect.iscoroutinefunction(create_fn):
121
+ message = await create_fn(**kwargs)
122
+ else:
123
+ loop = asyncio.get_running_loop()
124
+ message = await loop.run_in_executor(
125
+ None, functools.partial(create_fn, **kwargs)
126
+ )
127
+ except Exception as exc:
128
+ elapsed = (time.perf_counter() - start) * 1000
129
+ return AgentRun(
130
+ input=input,
131
+ error=f"{type(exc).__name__}: {exc}",
132
+ duration_ms=elapsed,
133
+ )
134
+
135
+ elapsed = (time.perf_counter() - start) * 1000
136
+ text = _extract_text(message)
137
+ tool_calls = _extract_tool_calls(message)
138
+ tokens = _extract_token_usage(message)
139
+
140
+ step = Step(
141
+ step_index=0,
142
+ input_text=input.query,
143
+ output_text=text,
144
+ tool_calls=tool_calls,
145
+ prompt_tokens=tokens["prompt_tokens"],
146
+ completion_tokens=tokens["completion_tokens"],
147
+ )
148
+
149
+ return AgentRun(
150
+ input=input,
151
+ steps=[step],
152
+ final_output=message,
153
+ duration_ms=elapsed,
154
+ total_prompt_tokens=tokens["prompt_tokens"],
155
+ total_completion_tokens=tokens["completion_tokens"],
156
+ )
157
+
158
+ async def run_stream(self, input: AgentInput | str) -> AsyncIterator[StreamEvent]:
159
+ """Stream execution events via Anthropic's streaming API."""
160
+ if isinstance(input, str):
161
+ input = AgentInput(query=input)
162
+
163
+ kwargs: dict[str, Any] = {
164
+ "model": self._model,
165
+ "max_tokens": self._max_tokens,
166
+ "messages": [{"role": "user", "content": input.query}],
167
+ }
168
+ if self._system:
169
+ kwargs["system"] = self._system
170
+
171
+ yield StreamEvent(event_type=StreamEventType.RUN_START)
172
+
173
+ try:
174
+ if hasattr(self._client, "messages") and hasattr(
175
+ self._client.messages, "stream"
176
+ ):
177
+ stream_fn = self._client.messages.stream
178
+ else:
179
+ # Fallback: run and synthesize
180
+ result = await self.run(input)
181
+ if result.error:
182
+ yield StreamEvent(
183
+ event_type=StreamEventType.ERROR, data=result.error
184
+ )
185
+ else:
186
+ yield StreamEvent(
187
+ event_type=StreamEventType.TEXT_DELTA,
188
+ data=_extract_text(result.final_output),
189
+ step_index=0,
190
+ )
191
+ yield StreamEvent(event_type=StreamEventType.RUN_END, data=result)
192
+ return
193
+
194
+ async with stream_fn(**kwargs) as stream:
195
+ async for event in stream:
196
+ event_type = getattr(event, "type", "")
197
+ if event_type == "content_block_delta":
198
+ delta = getattr(event, "delta", None)
199
+ if delta and getattr(delta, "type", "") == "text_delta":
200
+ yield StreamEvent(
201
+ event_type=StreamEventType.TEXT_DELTA,
202
+ data=getattr(delta, "text", ""),
203
+ )
204
+ except Exception as exc:
205
+ yield StreamEvent(
206
+ event_type=StreamEventType.ERROR,
207
+ data=f"{type(exc).__name__}: {exc}",
208
+ )
209
+
210
+ yield StreamEvent(event_type=StreamEventType.RUN_END)
@@ -0,0 +1,189 @@
1
+ """CrewAI adapter — wraps Crew.kickoff() as AgentAdapter.
2
+
3
+ Requires ``crewai`` to be installed. Converts CrewAI's CrewOutput
4
+ into CheckAgent's AgentRun trace format.
5
+
6
+ Streaming is synthesized from the final result since CrewAI does not
7
+ expose a streaming API for individual steps.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from collections.abc import AsyncIterator
14
+ from typing import Any
15
+
16
+ from checkagent.core.types import (
17
+ AgentInput,
18
+ AgentRun,
19
+ Step,
20
+ StreamEvent,
21
+ StreamEventType,
22
+ ToolCall,
23
+ )
24
+
25
+
26
+ def _ensure_crewai() -> None:
27
+ """Raise a clear error if crewai is not installed."""
28
+ try:
29
+ import crewai # noqa: F401
30
+ except ImportError:
31
+ raise ImportError(
32
+ "CrewAIAdapter requires crewai. "
33
+ "Install it with: pip install crewai"
34
+ ) from None
35
+
36
+
37
+ def _extract_steps(result: Any) -> list[Step]:
38
+ """Extract steps from CrewOutput.tasks_output or agent interactions."""
39
+ steps: list[Step] = []
40
+
41
+ # CrewOutput.tasks_output is a list of TaskOutput objects
42
+ tasks_output = getattr(result, "tasks_output", None) or []
43
+ for i, task_out in enumerate(tasks_output):
44
+ raw_text = getattr(task_out, "raw", "") or ""
45
+ description = getattr(task_out, "description", "") or ""
46
+ agent_name = getattr(task_out, "agent", "") or ""
47
+
48
+ step = Step(
49
+ step_index=i,
50
+ input_text=description,
51
+ output_text=str(raw_text),
52
+ metadata={"agent": str(agent_name)} if agent_name else {},
53
+ )
54
+ steps.append(step)
55
+
56
+ # If no tasks_output, create a single step from the raw result
57
+ if not steps:
58
+ raw = getattr(result, "raw", str(result))
59
+ steps.append(Step(step_index=0, input_text="", output_text=str(raw)))
60
+
61
+ return steps
62
+
63
+
64
+ def _extract_tool_calls(result: Any) -> list[ToolCall]:
65
+ """Extract tool calls from CrewOutput if available."""
66
+ calls: list[ToolCall] = []
67
+ tasks_output = getattr(result, "tasks_output", None) or []
68
+ for task_out in tasks_output:
69
+ # CrewAI TaskOutput may have tool_calls or tools_used
70
+ tool_calls = getattr(task_out, "tool_calls", None) or []
71
+ for tc in tool_calls:
72
+ if isinstance(tc, dict):
73
+ calls.append(ToolCall(
74
+ name=tc.get("name", "unknown"),
75
+ arguments=tc.get("arguments", tc.get("args", {})),
76
+ ))
77
+ else:
78
+ calls.append(ToolCall(
79
+ name=getattr(tc, "name", "unknown"),
80
+ arguments=getattr(tc, "arguments", {}),
81
+ ))
82
+ return calls
83
+
84
+
85
+ def _extract_token_usage(result: Any) -> dict[str, int | None]:
86
+ """Extract token usage from CrewOutput.token_usage."""
87
+ usage: dict[str, int | None] = {
88
+ "prompt_tokens": None,
89
+ "completion_tokens": None,
90
+ }
91
+ token_usage = getattr(result, "token_usage", None)
92
+ if token_usage and isinstance(token_usage, dict):
93
+ usage["prompt_tokens"] = token_usage.get("prompt_tokens")
94
+ usage["completion_tokens"] = token_usage.get("completion_tokens")
95
+ elif token_usage:
96
+ usage["prompt_tokens"] = getattr(token_usage, "prompt_tokens", None)
97
+ usage["completion_tokens"] = getattr(token_usage, "completion_tokens", None)
98
+ return usage
99
+
100
+
101
+ class CrewAIAdapter:
102
+ """Wraps a CrewAI Crew as an AgentAdapter.
103
+
104
+ Parameters
105
+ ----------
106
+ crew : Any
107
+ A CrewAI ``Crew`` instance.
108
+ """
109
+
110
+ def __init__(self, crew: Any) -> None:
111
+ _ensure_crewai()
112
+ self._crew = crew
113
+
114
+ async def run(self, input: AgentInput | str) -> AgentRun:
115
+ """Execute the crew and return an AgentRun trace."""
116
+ if isinstance(input, str):
117
+ input = AgentInput(query=input)
118
+
119
+ inputs = {"query": input.query, **input.context}
120
+
121
+ start = time.perf_counter()
122
+ try:
123
+ if hasattr(self._crew, "kickoff_async"):
124
+ result = await self._crew.kickoff_async(inputs=inputs)
125
+ else:
126
+ # Sync-only kickoff — run in executor
127
+ import asyncio
128
+ import functools
129
+
130
+ loop = asyncio.get_running_loop()
131
+ result = await loop.run_in_executor(
132
+ None, functools.partial(self._crew.kickoff, inputs=inputs)
133
+ )
134
+ except Exception as exc:
135
+ elapsed = (time.perf_counter() - start) * 1000
136
+ return AgentRun(
137
+ input=input,
138
+ error=f"{type(exc).__name__}: {exc}",
139
+ duration_ms=elapsed,
140
+ )
141
+
142
+ elapsed = (time.perf_counter() - start) * 1000
143
+ steps = _extract_steps(result)
144
+ tool_calls = _extract_tool_calls(result)
145
+ tokens = _extract_token_usage(result)
146
+
147
+ # Attach tool calls to the last step if any
148
+ if tool_calls and steps:
149
+ steps[-1] = Step(
150
+ step_index=steps[-1].step_index,
151
+ input_text=steps[-1].input_text,
152
+ output_text=steps[-1].output_text,
153
+ tool_calls=tool_calls,
154
+ metadata=steps[-1].metadata,
155
+ )
156
+
157
+ final_output = getattr(result, "raw", str(result))
158
+
159
+ return AgentRun(
160
+ input=input,
161
+ steps=steps,
162
+ final_output=final_output,
163
+ duration_ms=elapsed,
164
+ total_prompt_tokens=tokens["prompt_tokens"],
165
+ total_completion_tokens=tokens["completion_tokens"],
166
+ )
167
+
168
+ async def run_stream(self, input: AgentInput | str) -> AsyncIterator[StreamEvent]:
169
+ """Stream execution events (synthesized from final result)."""
170
+ yield StreamEvent(event_type=StreamEventType.RUN_START)
171
+
172
+ try:
173
+ result = await self.run(input)
174
+ if result.error:
175
+ yield StreamEvent(event_type=StreamEventType.ERROR, data=result.error)
176
+ else:
177
+ for step in result.steps:
178
+ yield StreamEvent(
179
+ event_type=StreamEventType.TEXT_DELTA,
180
+ data=step.output_text,
181
+ step_index=step.step_index,
182
+ )
183
+ yield StreamEvent(event_type=StreamEventType.RUN_END, data=result)
184
+ except Exception as exc:
185
+ yield StreamEvent(
186
+ event_type=StreamEventType.ERROR,
187
+ data=f"{type(exc).__name__}: {exc}",
188
+ )
189
+ yield StreamEvent(event_type=StreamEventType.RUN_END)
@@ -0,0 +1,109 @@
1
+ """Generic adapter — wraps any Python callable as an AgentAdapter.
2
+
3
+ Supports both sync and async callables. Sync functions are executed
4
+ in a thread pool executor to avoid blocking the event loop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import functools
11
+ import inspect
12
+ import time
13
+ from collections.abc import AsyncIterator, Callable
14
+ from typing import Any, overload
15
+
16
+ from checkagent.core.types import AgentInput, AgentRun, Step, StreamEvent, StreamEventType
17
+
18
+
19
+ class GenericAdapter:
20
+ """Wraps any Python callable to conform to the AgentAdapter protocol.
21
+
22
+ The callable should accept a string (the query) and return a string
23
+ or any value that becomes the final_output. Additional kwargs from
24
+ AgentInput.context are forwarded if the callable accepts **kwargs.
25
+ """
26
+
27
+ def __init__(self, fn: Callable[..., Any]) -> None:
28
+ self._fn = fn
29
+ self._is_async = inspect.iscoroutinefunction(fn)
30
+ self._accepts_kwargs = any(
31
+ p.kind == inspect.Parameter.VAR_KEYWORD
32
+ for p in inspect.signature(fn).parameters.values()
33
+ )
34
+
35
+ async def run(self, input: AgentInput | str) -> AgentRun:
36
+ """Execute the wrapped callable and return an AgentRun trace.
37
+
38
+ Accepts either an AgentInput or a plain string (converted automatically).
39
+ """
40
+ if isinstance(input, str):
41
+ input = AgentInput(query=input)
42
+ start = time.perf_counter()
43
+ kwargs = input.context if self._accepts_kwargs else {}
44
+
45
+ try:
46
+ if self._is_async:
47
+ result = await self._fn(input.query, **kwargs)
48
+ else:
49
+ loop = asyncio.get_running_loop()
50
+ result = await loop.run_in_executor(
51
+ None, functools.partial(self._fn, input.query, **kwargs)
52
+ )
53
+ except Exception as exc:
54
+ elapsed = (time.perf_counter() - start) * 1000
55
+ return AgentRun(
56
+ input=input,
57
+ error=f"{type(exc).__name__}: {exc}",
58
+ duration_ms=elapsed,
59
+ )
60
+
61
+ elapsed = (time.perf_counter() - start) * 1000
62
+ return AgentRun(
63
+ input=input,
64
+ steps=[Step(step_index=0, input_text=input.query, output_text=str(result))],
65
+ final_output=result,
66
+ duration_ms=elapsed,
67
+ )
68
+
69
+ async def run_stream(self, input: AgentInput) -> AsyncIterator[StreamEvent]:
70
+ """Fallback stream — runs the callable and synthesizes events."""
71
+ yield StreamEvent(event_type=StreamEventType.RUN_START)
72
+
73
+ result = await self.run(input)
74
+
75
+ if result.error:
76
+ yield StreamEvent(event_type=StreamEventType.ERROR, data=result.error)
77
+ else:
78
+ yield StreamEvent(
79
+ event_type=StreamEventType.TEXT_DELTA,
80
+ data=str(result.final_output),
81
+ step_index=0,
82
+ )
83
+
84
+ yield StreamEvent(event_type=StreamEventType.RUN_END, data=result)
85
+
86
+
87
+ @overload
88
+ def wrap(fn: Callable[..., Any]) -> GenericAdapter: ...
89
+
90
+
91
+ @overload
92
+ def wrap() -> Callable[[Callable[..., Any]], GenericAdapter]: ...
93
+
94
+
95
+ def wrap(
96
+ fn: Callable[..., Any] | None = None,
97
+ ) -> GenericAdapter | Callable[[Callable[..., Any]], GenericAdapter]:
98
+ """Wrap a callable as a GenericAdapter. Usable as decorator or function.
99
+
100
+ @wrap
101
+ async def my_agent(query: str) -> str:
102
+ ...
103
+
104
+ # or
105
+ adapter = wrap(my_sync_function)
106
+ """
107
+ if fn is not None:
108
+ return GenericAdapter(fn)
109
+ return GenericAdapter # type: ignore[return-value]