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.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. 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"]