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.
- checkagent/__init__.py +94 -0
- checkagent/adapters/__init__.py +32 -0
- checkagent/adapters/anthropic.py +210 -0
- checkagent/adapters/crewai.py +189 -0
- checkagent/adapters/generic.py +109 -0
- checkagent/adapters/langchain.py +241 -0
- checkagent/adapters/openai_agents.py +221 -0
- checkagent/adapters/pydantic_ai.py +188 -0
- checkagent/ci/__init__.py +40 -0
- checkagent/ci/entrypoint.py +100 -0
- checkagent/ci/junit_xml.py +310 -0
- checkagent/ci/quality_gate.py +169 -0
- checkagent/ci/reporter.py +192 -0
- checkagent/cli/__init__.py +28 -0
- checkagent/cli/demo.py +185 -0
- checkagent/cli/import_trace.py +200 -0
- checkagent/cli/init.py +150 -0
- checkagent/cli/migrate.py +66 -0
- checkagent/cli/run.py +42 -0
- checkagent/conversation/__init__.py +5 -0
- checkagent/conversation/session.py +227 -0
- checkagent/core/__init__.py +37 -0
- checkagent/core/adapter.py +26 -0
- checkagent/core/config.py +237 -0
- checkagent/core/cost.py +338 -0
- checkagent/core/plugin.py +266 -0
- checkagent/core/types.py +145 -0
- checkagent/datasets/__init__.py +12 -0
- checkagent/datasets/loader.py +127 -0
- checkagent/datasets/schema.py +75 -0
- checkagent/eval/__init__.py +44 -0
- checkagent/eval/aggregate.py +265 -0
- checkagent/eval/assertions.py +343 -0
- checkagent/eval/evaluator.py +123 -0
- checkagent/eval/metrics.py +237 -0
- checkagent/judge/__init__.py +34 -0
- checkagent/judge/consensus.py +127 -0
- checkagent/judge/judge.py +281 -0
- checkagent/judge/types.py +157 -0
- checkagent/judge/verdict.py +84 -0
- checkagent/mock/__init__.py +73 -0
- checkagent/mock/fault.py +605 -0
- checkagent/mock/llm.py +582 -0
- checkagent/mock/mcp.py +342 -0
- checkagent/mock/tool.py +487 -0
- checkagent/multiagent/__init__.py +25 -0
- checkagent/multiagent/credit.py +229 -0
- checkagent/multiagent/trace.py +235 -0
- checkagent/replay/__init__.py +44 -0
- checkagent/replay/cassette.py +196 -0
- checkagent/replay/engine.py +167 -0
- checkagent/replay/migration.py +211 -0
- checkagent/replay/recorder.py +161 -0
- checkagent/safety/__init__.py +70 -0
- checkagent/safety/compliance.py +438 -0
- checkagent/safety/conversation_scanner.py +138 -0
- checkagent/safety/evaluator.py +71 -0
- checkagent/safety/injection.py +194 -0
- checkagent/safety/pii.py +133 -0
- checkagent/safety/probes/__init__.py +26 -0
- checkagent/safety/probes/base.py +98 -0
- checkagent/safety/probes/injection.py +431 -0
- checkagent/safety/probes/jailbreak.py +219 -0
- checkagent/safety/probes/pii.py +126 -0
- checkagent/safety/probes/scope.py +101 -0
- checkagent/safety/refusal.py +141 -0
- checkagent/safety/system_prompt.py +121 -0
- checkagent/safety/taxonomy.py +65 -0
- checkagent/safety/tool_boundary.py +180 -0
- checkagent/streaming/__init__.py +7 -0
- checkagent/streaming/collector.py +107 -0
- checkagent/trace_import/__init__.py +22 -0
- checkagent/trace_import/base.py +39 -0
- checkagent/trace_import/json_importer.py +214 -0
- checkagent/trace_import/otel_importer.py +239 -0
- checkagent/trace_import/pii.py +123 -0
- checkagent/trace_import/testcase_gen.py +151 -0
- checkagent-0.0.1a1.dist-info/METADATA +178 -0
- checkagent-0.0.1a1.dist-info/RECORD +82 -0
- checkagent-0.0.1a1.dist-info/WHEEL +4 -0
- checkagent-0.0.1a1.dist-info/entry_points.txt +5 -0
- 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]
|