coder-eval 0.8.2__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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""No-op ("agentless") agent — the Null Object of the Agent hierarchy.
|
|
2
|
+
|
|
3
|
+
``NoOpAgent`` binds to ``AgentKind.NONE`` (selected via ``agent: {type: none}``)
|
|
4
|
+
for system / canary checks that reuse the eval infrastructure (sandbox,
|
|
5
|
+
``pre_run``, reports, evalboard, ADX) without running a coding agent. Its
|
|
6
|
+
``start`` / ``communicate`` / ``stop`` are no-ops and it makes no model API
|
|
7
|
+
call; ``communicate`` emits the standardized event protocol for a single empty
|
|
8
|
+
turn and returns the ``EventCollector``'s reduction (an empty
|
|
9
|
+
:class:`~coder_eval.models.results.TurnRecord`), so the orchestrator's normal
|
|
10
|
+
lifecycle runs unmodified and then checks the success criteria directly against
|
|
11
|
+
the sandbox.
|
|
12
|
+
|
|
13
|
+
See ``docs/TASK_DEFINITION_GUIDE.md`` (No-op / System Tasks) and issue #203.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from coder_eval.agent import Agent, AgentState
|
|
19
|
+
from coder_eval.agents.registry import AgentRegistry
|
|
20
|
+
from coder_eval.models import AgentKind, ApiRoute, NoneAgentConfig, TurnRecord
|
|
21
|
+
from coder_eval.streaming.callbacks import CompositeStreamCallback, StreamCallback
|
|
22
|
+
from coder_eval.streaming.collector import EventCollector
|
|
23
|
+
from coder_eval.streaming.events import (
|
|
24
|
+
AgentEndEvent,
|
|
25
|
+
AgentEndStatus,
|
|
26
|
+
AgentStartEvent,
|
|
27
|
+
TurnEndEvent,
|
|
28
|
+
TurnEndStatus,
|
|
29
|
+
TurnStartEvent,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@AgentRegistry.register(AgentKind.NONE, NoneAgentConfig)
|
|
34
|
+
class NoOpAgent(Agent[NoneAgentConfig]):
|
|
35
|
+
"""Agent that does nothing — every lifecycle method is a no-op.
|
|
36
|
+
|
|
37
|
+
Created and driven by the orchestrator exactly like any other agent, so no
|
|
38
|
+
``agentless`` branching is needed: the single signal is ``agent.type ==
|
|
39
|
+
AgentKind.NONE``. ``communicate`` is the SOLE emitter of one clean, balanced
|
|
40
|
+
event tree (``AgentStart`` -> ``TurnStart`` -> ``TurnEnd`` -> ``AgentEnd``,
|
|
41
|
+
all ``COMPLETED``) and returns the empty turn the ``EventCollector`` reduces
|
|
42
|
+
from it.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: NoneAgentConfig, route: ApiRoute | None = None) -> None:
|
|
46
|
+
self.config = config
|
|
47
|
+
self.route = route
|
|
48
|
+
|
|
49
|
+
async def start(
|
|
50
|
+
self,
|
|
51
|
+
working_directory: str,
|
|
52
|
+
*,
|
|
53
|
+
env_path_prepend: list[str] | None = None,
|
|
54
|
+
plugin_tools_dir: str | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""No-op: there is no agent process to launch."""
|
|
57
|
+
self._state = AgentState.WORKING
|
|
58
|
+
|
|
59
|
+
async def communicate(
|
|
60
|
+
self,
|
|
61
|
+
user_input: str,
|
|
62
|
+
*,
|
|
63
|
+
stream_callback: StreamCallback | None = None,
|
|
64
|
+
timeout: float | None = None,
|
|
65
|
+
max_turns: int | None = None,
|
|
66
|
+
) -> TurnRecord:
|
|
67
|
+
"""Return an empty turn without contacting any model.
|
|
68
|
+
|
|
69
|
+
Honors the streaming contract — sole emitter of a balanced event tree
|
|
70
|
+
(``AgentStart`` -> ``TurnStart`` -> ``TurnEnd`` -> ``AgentEnd``) — so the
|
|
71
|
+
task-log handler and renderers see a clean turn boundary. The returned
|
|
72
|
+
``TurnRecord`` is the ``EventCollector``'s reduction of those events.
|
|
73
|
+
"""
|
|
74
|
+
self._begin_turn()
|
|
75
|
+
|
|
76
|
+
task_id = str(self.config.type) # str() so a plugin subclass with a non-enum kind also works
|
|
77
|
+
turn_id = f"none-{self._iteration}"
|
|
78
|
+
collector = EventCollector()
|
|
79
|
+
emit = CompositeStreamCallback([c for c in (collector, stream_callback) if c is not None])
|
|
80
|
+
|
|
81
|
+
emit.on_event(AgentStartEvent(task_id=task_id, prompt=user_input, iteration=self._iteration))
|
|
82
|
+
emit.on_event(TurnStartEvent(task_id=task_id, turn_id=turn_id))
|
|
83
|
+
emit.on_event(TurnEndEvent(task_id=task_id, turn_id=turn_id, status=TurnEndStatus.COMPLETED))
|
|
84
|
+
emit.on_event(
|
|
85
|
+
AgentEndEvent(
|
|
86
|
+
task_id=task_id,
|
|
87
|
+
status=AgentEndStatus.COMPLETED,
|
|
88
|
+
iteration=self._iteration,
|
|
89
|
+
user_input=user_input,
|
|
90
|
+
agent_output="",
|
|
91
|
+
assistant_turn_count=0,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._end_turn_ok()
|
|
96
|
+
return collector.build_turn_record()
|
|
97
|
+
|
|
98
|
+
async def stop(self) -> None:
|
|
99
|
+
"""No-op: nothing to tear down."""
|
|
100
|
+
self._mark_stopped()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Agent registration and factory pattern for BYOA (bring-your-own-agent) support."""
|
|
2
|
+
|
|
3
|
+
# by-design model-hub ↔ registry type-level cycle; runtime imports are lazy per CE017
|
|
4
|
+
# pyright: reportImportCycles=false
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Imports kept TYPE_CHECKING-only (with future annotations) so this module imports
|
|
14
|
+
# nothing from coder_eval at runtime. That keeps the dependency one-way — the
|
|
15
|
+
# plugin loader and models layer import the registry, never the reverse — so there
|
|
16
|
+
# is no import cycle (CodeQL py/cyclic-import).
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from coder_eval.agent import Agent
|
|
19
|
+
from coder_eval.models import AgentKind, ApiRoute, BaseAgentConfig
|
|
20
|
+
|
|
21
|
+
MethodConfigT = TypeVar("MethodConfigT", bound="BaseAgentConfig")
|
|
22
|
+
AgentClassT = TypeVar("AgentClassT")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AgentRegistration[ConfigT: BaseAgentConfig]:
|
|
27
|
+
"""Metadata for a registered agent.
|
|
28
|
+
|
|
29
|
+
Stores the agent class and its expected config class for runtime validation.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
agent_class: type[Agent[Any]]
|
|
33
|
+
config_class: type[ConfigT]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentRegistry:
|
|
37
|
+
"""Global registry for custom agents.
|
|
38
|
+
|
|
39
|
+
Provides a decorator-based registration pattern that decouples agent
|
|
40
|
+
implementations from the orchestrator factory. Keyed by the agent *kind
|
|
41
|
+
string* so a built-in :class:`AgentKind` member and a plugin-supplied raw
|
|
42
|
+
string collide on the same key (``AgentKind`` is a ``StrEnum``): an external
|
|
43
|
+
plugin can register a brand-new kind that is not an enum member.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_registry: ClassVar[dict[str, AgentRegistration[Any]]] = {}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def register(
|
|
50
|
+
cls, agent_kind: str | AgentKind, config_class: type[MethodConfigT]
|
|
51
|
+
) -> Callable[[type[AgentClassT]], type[AgentClassT]]:
|
|
52
|
+
"""Decorator to register an agent class (identity-preserving).
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
@AgentRegistry.register(AgentKind.CLAUDE_CODE, ClaudeCodeAgentConfig)
|
|
56
|
+
class ClaudeCodeAgent(Agent[ClaudeCodeAgentConfig]):
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
agent_kind: The agent kind this agent implements — an ``AgentKind``
|
|
61
|
+
member (built-ins) or a raw kind string (plugins).
|
|
62
|
+
config_class: The config class this agent expects (e.g., ClaudeCodeAgentConfig)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A decorator that registers and returns the agent class unchanged (preserves type)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def decorator(agent_cls: type[AgentClassT]) -> type[AgentClassT]:
|
|
69
|
+
kind = str(agent_kind)
|
|
70
|
+
existing = cls._registry.get(kind)
|
|
71
|
+
# Re-registering the SAME classes is legitimate (idempotent built-in
|
|
72
|
+
# reload via load_plugins(force=True)). Re-registering a kind with a
|
|
73
|
+
# DIFFERENT implementation is a silent shadow: which agent runs would
|
|
74
|
+
# depend on entry-point discovery order, which isn't stable across
|
|
75
|
+
# environments — a reproducibility hole. Reject it loudly.
|
|
76
|
+
if existing is not None and (existing.agent_class, existing.config_class) != (agent_cls, config_class):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Agent kind {kind!r} is already registered to "
|
|
79
|
+
+ f"{existing.agent_class.__name__} ({existing.config_class.__name__}); "
|
|
80
|
+
+ f"{agent_cls.__name__} ({config_class.__name__}) cannot shadow it. "
|
|
81
|
+
+ "Two plugins must not claim the same agent.type."
|
|
82
|
+
)
|
|
83
|
+
cls._registry[kind] = AgentRegistration(
|
|
84
|
+
agent_class=agent_cls, # type: ignore[arg-type]
|
|
85
|
+
config_class=config_class,
|
|
86
|
+
)
|
|
87
|
+
return agent_cls
|
|
88
|
+
|
|
89
|
+
return decorator
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def get(cls, agent_kind: str | AgentKind) -> AgentRegistration[Any] | None:
|
|
93
|
+
"""Look up a registered agent by kind.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
agent_kind: The agent kind to look up (``AgentKind`` member or raw string)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
AgentRegistration if found, None otherwise
|
|
100
|
+
"""
|
|
101
|
+
return cls._registry.get(str(agent_kind))
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def list_kinds(cls) -> list[str]:
|
|
105
|
+
"""Registered agent kind strings (sorted for stable error messages)."""
|
|
106
|
+
return sorted(cls._registry)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def registrations(cls) -> list[AgentRegistration[Any]]:
|
|
110
|
+
"""All registered agent registrations (for config-class enumeration)."""
|
|
111
|
+
return list(cls._registry.values())
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def unregistered_kind_error(cls, agent_kind: str | AgentKind) -> ValueError:
|
|
115
|
+
"""The single ``ValueError`` for an unknown kind (shared by the factory and
|
|
116
|
+
``parse_agent_config``) so both report identically and list valid kinds."""
|
|
117
|
+
return ValueError(f"No agent registered for type {str(agent_kind)!r}. Registered kinds: {cls.list_kinds()}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def create_agent(
|
|
121
|
+
agent_kind: str | AgentKind,
|
|
122
|
+
config: BaseAgentConfig,
|
|
123
|
+
route: ApiRoute | None = None,
|
|
124
|
+
**kwargs: Any,
|
|
125
|
+
) -> Agent[Any]:
|
|
126
|
+
"""Factory function to create an agent by kind.
|
|
127
|
+
|
|
128
|
+
Validates that the config matches the agent's registered config class.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
agent_kind: The agent kind to instantiate (``AgentKind`` member or raw string)
|
|
132
|
+
config: Configuration object (must match the registered agent's config class)
|
|
133
|
+
route: Optional API routing configuration
|
|
134
|
+
**kwargs: Additional arguments passed to the agent constructor
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
An instance of the requested agent type
|
|
138
|
+
|
|
139
|
+
Plugins must already be loaded: callers reach a config object through
|
|
140
|
+
``parse_agent_config`` (which loads plugins), and the orchestrator / CLI also
|
|
141
|
+
load them up-front. ``create_agent`` deliberately does NOT import
|
|
142
|
+
``coder_eval.plugins`` itself, so ``plugins`` -> ``agents.registry`` stays a
|
|
143
|
+
one-way edge (no import cycle).
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError: If the agent_kind is not registered
|
|
147
|
+
TypeError: If the config type doesn't match the agent's expected config class
|
|
148
|
+
"""
|
|
149
|
+
registration = AgentRegistry.get(agent_kind)
|
|
150
|
+
if not registration:
|
|
151
|
+
raise AgentRegistry.unregistered_kind_error(agent_kind)
|
|
152
|
+
|
|
153
|
+
# Type check: ensure config matches the registered agent's config class
|
|
154
|
+
if not isinstance(config, registration.config_class):
|
|
155
|
+
raise TypeError(
|
|
156
|
+
f"Agent {agent_kind!r} expects {registration.config_class.__name__} "
|
|
157
|
+
+ f"but received {type(config).__name__}. "
|
|
158
|
+
+ f"Did you pass --type {agent_kind} with mismatched config?"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Instantiate the agent with the typed config
|
|
162
|
+
# Cast to Any to allow pyright to resolve the generic class instantiation
|
|
163
|
+
return cast(Any, registration.agent_class)(config, route=route, **kwargs)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""OS-thread-based deadline enforcer for async code.
|
|
2
|
+
|
|
3
|
+
Replaces ``asyncio.sleep``/``asyncio.wait_for`` as the primitive for enforcing
|
|
4
|
+
wall-clock timeouts on code that may block the event loop or be wrapped in
|
|
5
|
+
anyio cancel scopes that swallow asyncio.CancelledError. The timer runs on a
|
|
6
|
+
daemon OS thread, so it fires even when the event loop is starved or stuck
|
|
7
|
+
on subprocess I/O.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Self
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ThreadedWatchdog:
|
|
24
|
+
"""OS-thread-based deadline enforcer.
|
|
25
|
+
|
|
26
|
+
A ``threading.Timer`` fires at ``timeout_seconds`` and invokes ``on_timeout``
|
|
27
|
+
from the timer thread (NOT the asyncio event loop). After it fires,
|
|
28
|
+
``fired`` is True (readable from any thread).
|
|
29
|
+
|
|
30
|
+
``on_timeout`` MUST be synchronous and thread-safe. Exceptions it raises
|
|
31
|
+
are logged and swallowed so one bad callback never kills the timer thread
|
|
32
|
+
without a trace.
|
|
33
|
+
|
|
34
|
+
Typical use in async code:
|
|
35
|
+
|
|
36
|
+
def _on_timeout() -> None:
|
|
37
|
+
kill_subprocess_by_pid(pid) # sync, thread-safe
|
|
38
|
+
|
|
39
|
+
with ThreadedWatchdog(
|
|
40
|
+
timeout_seconds=1200,
|
|
41
|
+
on_timeout=_on_timeout,
|
|
42
|
+
asyncio_task_to_cancel=asyncio.current_task(),
|
|
43
|
+
label="turn timeout",
|
|
44
|
+
) as wd:
|
|
45
|
+
async for message in query(...):
|
|
46
|
+
...
|
|
47
|
+
if wd.fired:
|
|
48
|
+
raise TurnTimeoutError(...)
|
|
49
|
+
|
|
50
|
+
Notes:
|
|
51
|
+
- ``timeout_seconds`` of None or <= 0 → no-op watchdog (no timer started).
|
|
52
|
+
This lets callers write a uniform ``with`` block regardless of whether
|
|
53
|
+
a timeout is configured.
|
|
54
|
+
- When ``asyncio_task_to_cancel`` is provided, the timer thread also
|
|
55
|
+
calls ``loop.call_soon_threadsafe(task.cancel)`` after ``on_timeout``.
|
|
56
|
+
This delivers cancellation across the thread boundary so mock-based
|
|
57
|
+
tests (no real subprocess) still unwind at the deadline.
|
|
58
|
+
- Each instance is single-use. Re-entering the ``with`` block after exit
|
|
59
|
+
is not supported.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
timeout_seconds: float | None,
|
|
66
|
+
on_timeout: Callable[[], None],
|
|
67
|
+
asyncio_task_to_cancel: asyncio.Task[object] | None = None,
|
|
68
|
+
label: str = "watchdog",
|
|
69
|
+
) -> None:
|
|
70
|
+
self._timeout = timeout_seconds
|
|
71
|
+
self._on_timeout = on_timeout
|
|
72
|
+
self._task_to_cancel = asyncio_task_to_cancel
|
|
73
|
+
self._label = label
|
|
74
|
+
self._timer: threading.Timer | None = None
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
self._fired = False
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def fired(self) -> bool:
|
|
80
|
+
"""True if the timer has fired (thread-safe)."""
|
|
81
|
+
with self._lock:
|
|
82
|
+
return self._fired
|
|
83
|
+
|
|
84
|
+
def _fire(self) -> None:
|
|
85
|
+
"""Timer-thread callback. Short, exception-safe."""
|
|
86
|
+
with self._lock:
|
|
87
|
+
if self._fired:
|
|
88
|
+
return
|
|
89
|
+
self._fired = True
|
|
90
|
+
logger.warning("%s fired after %.1fs — hard-killing subprocess", self._label, self._timeout or 0)
|
|
91
|
+
try:
|
|
92
|
+
self._on_timeout()
|
|
93
|
+
except Exception:
|
|
94
|
+
# Log-and-swallow: a bad on_timeout callback must not crash the
|
|
95
|
+
# timer thread silently. `logger.exception` captures the traceback.
|
|
96
|
+
logger.exception("%s on_timeout callback raised", self._label)
|
|
97
|
+
if self._task_to_cancel is not None:
|
|
98
|
+
# Loop may already be closed (e.g. interpreter shutdown), in
|
|
99
|
+
# which case call_soon_threadsafe raises RuntimeError — we've
|
|
100
|
+
# already done the kill, the cancel is a best-effort secondary.
|
|
101
|
+
with contextlib.suppress(RuntimeError):
|
|
102
|
+
loop = self._task_to_cancel.get_loop()
|
|
103
|
+
loop.call_soon_threadsafe(self._task_to_cancel.cancel)
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> Self:
|
|
106
|
+
if self._timeout is None or self._timeout <= 0:
|
|
107
|
+
return self
|
|
108
|
+
self._timer = threading.Timer(self._timeout, self._fire)
|
|
109
|
+
self._timer.daemon = True
|
|
110
|
+
self._timer.start()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
114
|
+
if self._timer is not None:
|
|
115
|
+
self._timer.cancel()
|
|
116
|
+
self._timer = None
|
coder_eval/analysis.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Analysis tools for evaluation results.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for analyzing and aggregating evaluation data,
|
|
4
|
+
particularly command telemetry statistics.
|
|
5
|
+
|
|
6
|
+
Separation of Concerns:
|
|
7
|
+
- Orchestrator: Coordinates evaluation execution
|
|
8
|
+
- Analysis: Processes results into insights
|
|
9
|
+
- Reports: Formats insights for human consumption
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections import Counter
|
|
13
|
+
|
|
14
|
+
from .models import CommandStatistics, CommandTelemetry, SlowestCommandInfo, TurnRecord
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def calculate_command_statistics(turns: list[TurnRecord]) -> CommandStatistics:
|
|
18
|
+
"""Aggregate command telemetry across all turns, including crashed partials.
|
|
19
|
+
|
|
20
|
+
Partial-record commands are real executed work (sandbox + SDK session persist on retry).
|
|
21
|
+
"""
|
|
22
|
+
all_commands: list[CommandTelemetry] = []
|
|
23
|
+
for turn in turns:
|
|
24
|
+
all_commands.extend(turn.commands)
|
|
25
|
+
|
|
26
|
+
if not all_commands:
|
|
27
|
+
return CommandStatistics(total_commands=0)
|
|
28
|
+
|
|
29
|
+
# Count by tool type
|
|
30
|
+
commands_by_tool: dict[str, int] = {}
|
|
31
|
+
for cmd in all_commands:
|
|
32
|
+
commands_by_tool[cmd.tool_name] = commands_by_tool.get(cmd.tool_name, 0) + 1
|
|
33
|
+
|
|
34
|
+
# Timing statistics (only for commands with duration data)
|
|
35
|
+
# Use `is not None` to include valid 0.0ms durations
|
|
36
|
+
total_time = sum(cmd.duration_ms for cmd in all_commands if cmd.duration_ms is not None)
|
|
37
|
+
timed_count = sum(1 for cmd in all_commands if cmd.duration_ms is not None)
|
|
38
|
+
avg_time = total_time / timed_count if timed_count > 0 else 0
|
|
39
|
+
|
|
40
|
+
# Find slowest commands (type-safe using SlowestCommandInfo model)
|
|
41
|
+
commands_with_timing = [c for c in all_commands if c.duration_ms is not None]
|
|
42
|
+
slowest = sorted(commands_with_timing, key=lambda x: x.duration_ms or 0, reverse=True)[:5]
|
|
43
|
+
|
|
44
|
+
slowest_info = [
|
|
45
|
+
SlowestCommandInfo(
|
|
46
|
+
tool=cmd.tool_name,
|
|
47
|
+
duration_ms=cmd.duration_ms if cmd.duration_ms is not None else 0.0,
|
|
48
|
+
parameters=cmd.parameters,
|
|
49
|
+
tool_id=cmd.tool_id,
|
|
50
|
+
)
|
|
51
|
+
for cmd in slowest
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Success/failure/unknown rates
|
|
55
|
+
successful = sum(1 for c in all_commands if c.result_status == "success")
|
|
56
|
+
failed = sum(1 for c in all_commands if c.result_status == "error")
|
|
57
|
+
unknown = sum(1 for c in all_commands if c.result_status == "unknown" or c.result_status is None)
|
|
58
|
+
|
|
59
|
+
# Calculate success rate excluding unknown (avoid division by zero)
|
|
60
|
+
known_commands = successful + failed
|
|
61
|
+
success_rate = (successful / known_commands * 100) if known_commands > 0 else 0.0
|
|
62
|
+
|
|
63
|
+
# Most common command sequence (3-grams)
|
|
64
|
+
sequences = []
|
|
65
|
+
for turn in turns:
|
|
66
|
+
cmds = [c.tool_name for c in turn.commands]
|
|
67
|
+
# Build 3-command sequences
|
|
68
|
+
for i in range(len(cmds) - 2):
|
|
69
|
+
sequences.append(f"{cmds[i]} → {cmds[i + 1]} → {cmds[i + 2]}")
|
|
70
|
+
|
|
71
|
+
# Find most frequent sequence (handle edge case of no sequences)
|
|
72
|
+
most_common = None
|
|
73
|
+
if sequences:
|
|
74
|
+
counter = Counter(sequences)
|
|
75
|
+
most_common = counter.most_common(1)[0][0]
|
|
76
|
+
|
|
77
|
+
return CommandStatistics(
|
|
78
|
+
total_commands=len(all_commands),
|
|
79
|
+
commands_by_tool=commands_by_tool,
|
|
80
|
+
total_command_time_ms=total_time,
|
|
81
|
+
avg_command_time_ms=avg_time,
|
|
82
|
+
slowest_commands=slowest_info,
|
|
83
|
+
successful_commands=successful,
|
|
84
|
+
failed_commands=failed,
|
|
85
|
+
unknown_commands=unknown,
|
|
86
|
+
success_rate=success_rate,
|
|
87
|
+
most_common_sequence=most_common,
|
|
88
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command-line interface for coder_eval."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from coder_eval.telemetry import track_command
|
|
6
|
+
|
|
7
|
+
from .aggregate_command import aggregate_command
|
|
8
|
+
from .console import console
|
|
9
|
+
from .evaluate_command import evaluate_command
|
|
10
|
+
from .plan_command import plan_command
|
|
11
|
+
from .report_command import report_command
|
|
12
|
+
from .run_command import run_command
|
|
13
|
+
from .run_task_internal_command import run_task_internal_command
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Create the Typer app
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="coder-eval",
|
|
19
|
+
help="A framework for evaluating AI coding agents",
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _version_callback(value: bool) -> None:
|
|
25
|
+
"""Print the installed coder-eval version and exit (eager `--version`)."""
|
|
26
|
+
if value:
|
|
27
|
+
from coder_eval import __version__
|
|
28
|
+
|
|
29
|
+
console.print(__version__)
|
|
30
|
+
raise typer.Exit(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback(invoke_without_command=True)
|
|
34
|
+
def main(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
version: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--version",
|
|
39
|
+
help="Show the installed coder-eval version and exit.",
|
|
40
|
+
callback=_version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""A framework for evaluating AI coding agents.
|
|
45
|
+
|
|
46
|
+
Run 'coder-eval COMMAND --help' for help on a specific command.
|
|
47
|
+
|
|
48
|
+
Available commands:
|
|
49
|
+
- run: Execute evaluation tasks
|
|
50
|
+
- plan: Validate task files (dry-run)
|
|
51
|
+
- evaluate: Run criteria against a directory without an agent
|
|
52
|
+
- report: Display or export evaluation reports
|
|
53
|
+
- aggregate: Rebuild run.json/run.md from finalized task.json files
|
|
54
|
+
"""
|
|
55
|
+
# Discover and register agents (built-in + third-party plugins) before any
|
|
56
|
+
# subcommand resolves a task or builds an agent.
|
|
57
|
+
from coder_eval.plugins import load_plugins
|
|
58
|
+
|
|
59
|
+
load_plugins()
|
|
60
|
+
|
|
61
|
+
# One-time telemetry init (no-op when disabled / no connection string).
|
|
62
|
+
# Runs before the no-subcommand early-exit so even `--help` inits harmlessly.
|
|
63
|
+
from coder_eval import __version__
|
|
64
|
+
from coder_eval.telemetry import init_telemetry
|
|
65
|
+
|
|
66
|
+
init_telemetry(version=__version__)
|
|
67
|
+
|
|
68
|
+
# If no subcommand was invoked, show help and exit
|
|
69
|
+
if ctx.invoked_subcommand is None:
|
|
70
|
+
console.print(ctx.get_help())
|
|
71
|
+
raise typer.Exit(0)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Register core commands. Each public command is wrapped with track_command so it
|
|
75
|
+
# emits a CoderEval.Cli.<name> event (Status/DurationMs/ErrorType) on completion;
|
|
76
|
+
# functools.wraps preserves the signature so Typer still parses each command's flags.
|
|
77
|
+
app.command(name="run")(track_command("run")(run_command))
|
|
78
|
+
app.command(name="plan")(track_command("plan")(plan_command))
|
|
79
|
+
app.command(name="evaluate")(track_command("evaluate")(evaluate_command))
|
|
80
|
+
app.command(name="report")(track_command("report")(report_command))
|
|
81
|
+
app.command(name="aggregate")(track_command("aggregate")(aggregate_command))
|
|
82
|
+
# Hidden internal command invoked inside the Docker container only — UNWRAPPED
|
|
83
|
+
# (it runs inside the run-task subprocess and would double-count / pollute events).
|
|
84
|
+
app.command(name="_run-task-internal", hidden=True)(run_task_internal_command)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["app"]
|