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,811 @@
1
+ """Antigravity agent implementation using the official google-antigravity SDK.
2
+
3
+ The backend drives Google's Antigravity agent *local harness* (the bundled
4
+ ``localharness`` binary shipped in the ``google-antigravity`` wheel) via the
5
+ SDK's :class:`LocalAgentConfig`. It authenticates against the Gemini Developer
6
+ API with ``GEMINI_API_KEY`` and runs entirely on the local machine, editing
7
+ files inside the sandbox working directory — so coder_eval's on-disk success
8
+ criteria see the agent's writes exactly as they do for Claude / Codex.
9
+
10
+ Why this surface (and not the branded ``agy`` CLI or the remote SDK): the
11
+ standalone Antigravity CLI cannot authenticate headlessly with a Gemini API key
12
+ (only interactive OAuth), and the Interactions API runs in a *remote* cloud
13
+ sandbox whose edits never land in our local dir. The local harness is the only
14
+ non-deprecated path that satisfies headless + GEMINI_API_KEY + local execution.
15
+
16
+ All SDK imports are lazy (inside ``start`` / helpers), mirroring CodexAgent, so
17
+ this module imports cleanly when the optional ``[antigravity]`` extra is absent;
18
+ a missing SDK surfaces as a clear install hint at ``start()``.
19
+ """
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import logging
24
+ import os
25
+ import time
26
+ from collections.abc import AsyncIterator
27
+ from contextlib import AsyncExitStack
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from coder_eval.agent import Agent, AgentState
33
+ from coder_eval.agents._logging import PrefixedAdapter
34
+ from coder_eval.agents.registry import AgentRegistry
35
+ from coder_eval.agents.watchdog import ThreadedWatchdog
36
+ from coder_eval.config import settings
37
+ from coder_eval.errors import (
38
+ AgentCrashError,
39
+ TurnTimeoutError,
40
+ truncate_crash_message,
41
+ )
42
+ from coder_eval.models import (
43
+ AgentKind,
44
+ AntigravityAgentConfig,
45
+ ApiRoute,
46
+ AssistantMessage,
47
+ CommandTelemetry,
48
+ ContentBlock,
49
+ DirectRoute,
50
+ TokenUsage,
51
+ TranscriptMessage,
52
+ TurnRecord,
53
+ )
54
+ from coder_eval.pricing import calculate_cost
55
+ from coder_eval.streaming.callbacks import CompositeStreamCallback, StreamCallback
56
+ from coder_eval.streaming.collector import EventCollector
57
+ from coder_eval.streaming.events import (
58
+ AgentEndEvent,
59
+ AgentEndStatus,
60
+ AgentStartEvent,
61
+ TextChunkEvent,
62
+ ToolEndEvent,
63
+ ToolEndStatus,
64
+ ToolStartEvent,
65
+ TurnEndEvent,
66
+ TurnEndStatus,
67
+ TurnStartEvent,
68
+ )
69
+ from coder_eval.utils import expand_env_vars
70
+
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ # Serializes the transient ``os.environ['PATH']`` prepend around the localharness
75
+ # subprocess spawn (see ``AntigravityAgent._harness_spawn_guard``). The Antigravity
76
+ # SDK's ``subprocess.Popen`` inherits the parent process's ``os.environ`` and exposes
77
+ # NO env seam, so making mock CLIs shadow real ones forces a global mutation; this
78
+ # lock keeps concurrent host-mode starts (``run_batch`` fans them out on one event
79
+ # loop) from leaking one task's mock dirs onto another's harness. Lazily created and
80
+ # rebound per running loop so pytest's per-test loops don't reuse a stale-loop lock.
81
+ _HARNESS_SPAWN_LOCK: asyncio.Lock | None = None
82
+ _HARNESS_SPAWN_LOCK_LOOP: asyncio.AbstractEventLoop | None = None
83
+
84
+
85
+ def _harness_spawn_lock() -> asyncio.Lock:
86
+ """Return the process-wide harness-spawn lock, bound to the running loop."""
87
+ global _HARNESS_SPAWN_LOCK, _HARNESS_SPAWN_LOCK_LOOP
88
+ loop = asyncio.get_running_loop()
89
+ if _HARNESS_SPAWN_LOCK is None or _HARNESS_SPAWN_LOCK_LOOP is not loop:
90
+ _HARNESS_SPAWN_LOCK = asyncio.Lock()
91
+ _HARNESS_SPAWN_LOCK_LOOP = loop
92
+ return _HARNESS_SPAWN_LOCK
93
+
94
+
95
+ # Recommended Gemini coding model when a task pins no ``agent.model`` and neither
96
+ # ``--model`` nor ``ANTIGRAVITY_MODEL`` is set. Gemini 3.1 Pro is the current
97
+ # flagship agentic-coding model (the earlier ``gemini-3-pro-preview`` is retired);
98
+ # ``medium`` thinking is its recommended daily-driver default.
99
+ _DEFAULT_MODEL = "gemini-3.1-pro-preview"
100
+
101
+ # Antigravity builtin tool name -> canonical Claude-ish tool name, so cross-agent
102
+ # success criteria (command_executed / commands_efficiency / skill_triggered) and
103
+ # reports key on the SAME tool names the Claude / Codex backends emit. Unmapped
104
+ # tool names pass through unchanged.
105
+ _ANTIGRAVITY_TO_CLAUDE_TOOL_MAP: dict[str, str] = {
106
+ "run_command": "Bash",
107
+ "create_file": "Write",
108
+ "edit_file": "Edit",
109
+ "view_file": "Read",
110
+ "search_directory": "Grep",
111
+ "find_file": "Glob",
112
+ "list_directory": "LS",
113
+ "start_subagent": "Task",
114
+ "search_web": "WebSearch",
115
+ "generate_image": "GenerateImage",
116
+ "ask_question": "AskUser",
117
+ "finish": "Finish",
118
+ }
119
+
120
+ # Tool-call arg keys the harness ADDS at completion (the result payload), not
121
+ # model-supplied inputs — stripped from CommandTelemetry.parameters and mined for
122
+ # the tool result instead. This is the STATIC backstop; the live mapping ALSO
123
+ # strips any key that first appears at tool-DONE (see ``_params``), so tool-
124
+ # specific result fields (LS ``results``, WebSearch ``summary``) never leak into
125
+ # parameters — important because ``skill_triggered`` substring-searches every
126
+ # parameter value and a leaked result could otherwise false-positive.
127
+ _RESULT_ARG_KEYS: frozenset[str] = frozenset(
128
+ {"exit_code", "combined_output", "diff_block", "output", "stdout", "stderr", "result", "results", "summary"}
129
+ )
130
+
131
+ # Antigravity per-tool INPUT-arg key -> canonical (Claude-ish) key, so cross-agent
132
+ # success criteria (command_executed keys on Bash ``parameters["command"]``; LS on
133
+ # ``path``) and reports read the SAME parameter names the Claude/Codex backends
134
+ # emit. Keyed by the canonical tool name (post tool-name mapping). Unlisted keys
135
+ # pass through unchanged.
136
+ _ANTIGRAVITY_ARG_RENAME: dict[str, dict[str, str]] = {
137
+ "Bash": {"command_line": "command"},
138
+ "LS": {"directory_path": "path"},
139
+ }
140
+
141
+ # google.antigravity.types.Step{Status,Type,Source,Target} VALUES we branch on,
142
+ # mirrored as plain strings so this module needs no SDK import (the SDK is an
143
+ # optional extra; only ``start()`` touches it). Named constants — not bare string
144
+ # literals — so an antigravity StepStatus.ERROR comparison is not mistaken for a
145
+ # coder_eval FinalStatus member-name denylist (lint rule CE018).
146
+ _STATUS_DONE = "DONE"
147
+ _STATUS_ERROR = "ERROR"
148
+ _TYPE_THINKING = "THINKING"
149
+ _TYPE_TEXT_RESPONSE = "TEXT_RESPONSE"
150
+ _SOURCE_MODEL = "MODEL"
151
+ _TARGET_USER = "TARGET_USER"
152
+
153
+
154
+ def _enum_value(x: Any) -> Any:
155
+ """Return a (possibly str-enum) value as its plain ``.value``, else itself."""
156
+ return getattr(x, "value", x)
157
+
158
+
159
+ def _to_token_usage(usage: Any, model: str | None) -> TokenUsage:
160
+ """Map a ``google.antigravity.types.UsageMetadata`` to coder_eval ``TokenUsage``.
161
+
162
+ Gemini reports ``prompt`` (with ``cached`` as a subset), ``candidates`` (output
163
+ excluding thinking) and ``thoughts`` (reasoning). coder_eval's buckets:
164
+ uncached input = prompt - cached; cache_read = cached; cache_creation = 0
165
+ (Gemini bills no separate cache-write fee); output = candidates + thoughts
166
+ (Gemini bills thinking as output). Cost is rate-carded from the bare model id.
167
+ """
168
+ prompt = getattr(usage, "prompt_token_count", 0) or 0
169
+ cached = getattr(usage, "cached_content_token_count", 0) or 0
170
+ candidates = getattr(usage, "candidates_token_count", 0) or 0
171
+ thoughts = getattr(usage, "thoughts_token_count", 0) or 0
172
+ uncached_input = max(prompt - cached, 0)
173
+ output = candidates + thoughts
174
+ cost = calculate_cost(model, uncached_input, output, 0, cached) if model else None
175
+ return TokenUsage(
176
+ uncached_input_tokens=uncached_input,
177
+ output_tokens=output,
178
+ cache_creation_input_tokens=0,
179
+ cache_read_input_tokens=cached,
180
+ total_cost_usd=cost,
181
+ )
182
+
183
+
184
+ @AgentRegistry.register(AgentKind.ANTIGRAVITY, AntigravityAgentConfig)
185
+ class AntigravityAgent(Agent[AntigravityAgentConfig]):
186
+ """Implementation of the Agent interface for Google Antigravity (Gemini)."""
187
+
188
+ def __init__(
189
+ self,
190
+ config: AntigravityAgentConfig,
191
+ route: ApiRoute | None = None,
192
+ *,
193
+ instance_name: str = "antigravity",
194
+ ):
195
+ """Initialize the Antigravity agent.
196
+
197
+ Args:
198
+ config: Agent configuration.
199
+ route: API routing configuration (unused — Antigravity authenticates via
200
+ GEMINI_API_KEY against the Gemini Developer API; kept for parity).
201
+ instance_name: Short label used to prefix this instance's log records.
202
+ """
203
+ self.config = config
204
+ self.route = route or DirectRoute()
205
+ self.working_directory: Path | None = None
206
+ # The live SDK Agent session + its AsyncExitStack (entered in start(),
207
+ # closed in stop()). The exit-stack teardown terminates the localharness
208
+ # subprocess, so reaping it is what stop()/kill() rely on.
209
+ self._sdk_agent: Any = None
210
+ self._exit_stack: AsyncExitStack | None = None
211
+ # Absolute dirs to prepend to PATH so sandbox mock CLIs shadow real ones
212
+ # for the harness's run_command tool — applied at spawn (see start()).
213
+ self._env_path_prepend: list[str] = []
214
+ # _state / _iteration / _iteration_was_incremented / pending_turn lifecycle
215
+ # bookkeeping lives on the Agent base class (shared defaults + helpers).
216
+ self._log = PrefixedAdapter(logger, {"prefix": instance_name})
217
+
218
+ def _effective_model(self) -> str:
219
+ """Resolve the model: task ``agent.model`` > ``ANTIGRAVITY_MODEL`` > default."""
220
+ return self.config.model or settings.antigravity_model or _DEFAULT_MODEL
221
+
222
+ def _resolve_skills_paths(self, plugin_tools_dir: str | None) -> list[str]:
223
+ """Resolve skill search-path roots for the harness's native ``skills_paths``.
224
+
225
+ Mirrors the source resolution the Codex backend uses: collect ``type: local``
226
+ plugin paths from ``config.plugins`` (env-expanded) plus the runtime
227
+ ``plugin_tools_dir``. For each source, hand the harness the directory that
228
+ *directly* parents skill dirs — either ``<source>/skills`` (a plugin-marketplace
229
+ / repo root) or ``<source>`` itself (already a skills dir) — whichever actually
230
+ contains a ``<skill>/SKILL.md``. The harness auto-discovers skills under those
231
+ roots; no symlinking is needed (unlike Codex, Antigravity takes search paths).
232
+ """
233
+ sources: list[Path] = []
234
+ for plugin in self.config.plugins or []:
235
+ if not (isinstance(plugin, dict) and plugin.get("type") == "local"):
236
+ continue
237
+ raw = plugin.get("path")
238
+ if not raw:
239
+ continue
240
+ expanded = expand_env_vars(raw)
241
+ path = Path(expanded)
242
+ if path.is_dir():
243
+ sources.append(path)
244
+ else:
245
+ # Loud: an unresolved env var (e.g. unset $SKILLS_REPO_PATH) or a
246
+ # missing dir silently drops the skills, so the agent runs blind.
247
+ hint = "env var likely unset" if "$" in expanded else "path does not exist"
248
+ self._log.warning("Plugin skills path did not resolve: %r → %r (%s)", raw, expanded, hint)
249
+ if plugin_tools_dir and Path(plugin_tools_dir).is_dir():
250
+ sources.append(Path(plugin_tools_dir))
251
+
252
+ roots: list[str] = []
253
+ seen: set[str] = set()
254
+ for source in sources:
255
+ # Prefer the nested ``skills/`` layout (repo root) over the source itself.
256
+ for candidate in (source / "skills", source):
257
+ if candidate.is_dir() and any(
258
+ (child / "SKILL.md").exists() for child in candidate.iterdir() if child.is_dir()
259
+ ):
260
+ resolved = str(candidate.resolve())
261
+ if resolved not in seen:
262
+ seen.add(resolved)
263
+ roots.append(resolved)
264
+ break # first matching layout per source wins
265
+ if sources and not roots:
266
+ self._log.warning(
267
+ "0 skills discovered under %s; check the plugin path points at a skills repo root",
268
+ [str(s) for s in sources],
269
+ )
270
+ else:
271
+ self._log.debug("Antigravity skills_paths resolved: %s", roots)
272
+ return roots
273
+
274
+ def _resolve_workspaces(self, skills_paths: list[str]) -> list[str]:
275
+ """Workspace roots for the harness's ``workspace_only`` file-tool policy.
276
+
277
+ The sandbox working directory (the write target) plus the resolved skill
278
+ roots. ``skills_paths`` only drives skill *discovery*; the file-tool
279
+ allowlist is governed solely by ``workspaces``, so the skill roots must
280
+ appear here too — otherwise the agent discovers a skill but every read of
281
+ its ``SKILL.md`` is denied as out-of-workspace. The roots are bind-mounted
282
+ into the sandbox at the same path by the shared docker plugin auto-mount,
283
+ mirroring how Claude reads skills from the mounted plugin path.
284
+ """
285
+ return [str(self.working_directory), *skills_paths]
286
+
287
+ async def start(
288
+ self,
289
+ working_directory: str,
290
+ *,
291
+ env_path_prepend: list[str] | None = None,
292
+ plugin_tools_dir: str | None = None,
293
+ ) -> None:
294
+ """Initialize and start the Antigravity agent's local harness session.
295
+
296
+ Args:
297
+ working_directory: Path to the sandbox working directory. The primary
298
+ ``workspace`` so file writes (and run_command) operate there —
299
+ process-cwd-independent, so concurrent host-mode tasks don't race.
300
+ Resolved skill roots are added alongside it so the agent can read
301
+ skill files — see ``_resolve_workspaces``.
302
+ env_path_prepend: Absolute directories to prepend to PATH (typically the
303
+ resolved ``SandboxConfig.mock_path_dirs``) so mock CLIs shadow the real
304
+ ones for the harness's ``run_command`` tool — same mock-shadowing
305
+ contract as the Claude/Codex backends. The Antigravity SDK spawns the
306
+ localharness via ``subprocess.Popen`` with no env seam, so the prepend
307
+ is applied by transiently mutating ``os.environ['PATH']`` across the
308
+ spawn (see ``_harness_spawn_guard``).
309
+ plugin_tools_dir: A skills/plugin source root. Resolved (together with
310
+ ``config.plugins``) into the harness's native ``skills_paths`` so the
311
+ agent can discover and engage UiPath skills — see ``_resolve_skills_paths``.
312
+ """
313
+ self.working_directory = Path(working_directory)
314
+ self._env_path_prepend = list(env_path_prepend or [])
315
+ self._state = AgentState.WORKING
316
+
317
+ try:
318
+ from google.antigravity import Agent as SdkAgent # pyright: ignore[reportMissingImports]
319
+ from google.antigravity import LocalAgentConfig, types # pyright: ignore[reportMissingImports]
320
+ from google.antigravity.hooks import policy # pyright: ignore[reportMissingImports]
321
+ except ImportError as e:
322
+ raise RuntimeError(
323
+ "Antigravity SDK not installed. Install with: pip install 'coder-eval[antigravity]'"
324
+ ) from e
325
+
326
+ try:
327
+ # GEMINI_API_KEY authenticates the harness. None lets the SDK read it
328
+ # from the environment itself (and raise a clear error if truly unset).
329
+ api_key = os.getenv("GEMINI_API_KEY") or None
330
+ skills_paths = self._resolve_skills_paths(plugin_tools_dir)
331
+ cfg = LocalAgentConfig(
332
+ model=self._effective_model(),
333
+ api_key=api_key,
334
+ # File tools are confined to ``workspaces`` by the auto-prepended
335
+ # workspace_only policy. Scope to the sandbox workdir (the write
336
+ # target — process-cwd-independent so concurrent host-mode tasks
337
+ # don't race) PLUS the resolved skill roots, so the agent can READ
338
+ # each SKILL.md. ``skills_paths`` only feeds discovery; the file-tool
339
+ # allowlist is ``workspaces`` alone, so without the roots here every
340
+ # skill read is denied as out-of-workspace. The roots are already
341
+ # bind-mounted into the sandbox at the same path by the shared
342
+ # docker plugin auto-mount (the path Claude reads skills from too).
343
+ workspaces=self._resolve_workspaces(skills_paths),
344
+ # Autonomous execution: approve every tool call (incl. run_command),
345
+ # which the default LocalAgentConfig policy would otherwise deny.
346
+ policies=[policy.allow_all()],
347
+ system_instructions=self.config.system_prompt or None,
348
+ # Skill discovery: hand the harness the search-path roots that parent
349
+ # the UiPath skill dirs. Unlike Codex (which symlinks into
350
+ # .agents/skills/), Antigravity takes skill search paths natively.
351
+ skills_paths=skills_paths,
352
+ )
353
+ # Attach the configured thinking level (reasoning effort) onto every
354
+ # resolved model's Gemini endpoint. The SDK validates the model list in
355
+ # a model_validator; we set options on the resolved targets after build.
356
+ level = types.ThinkingLevel(self.config.thinking_level)
357
+ for target in cfg.models or []:
358
+ endpoint = getattr(target, "endpoint", None)
359
+ if isinstance(endpoint, types.GeminiAPIEndpoint):
360
+ endpoint.options = types.GeminiModelOptions(thinking_level=level)
361
+
362
+ # Enter the SDK Agent context (boots the localharness subprocess +
363
+ # opens the conversation). Held open across communicate() calls and
364
+ # closed in stop(). The spawn guard prepends the mock dirs onto
365
+ # os.environ['PATH'] across the whole context-entry (subprocess spawn
366
+ # + session open — the child keeps the env it was spawned with), then
367
+ # restores it.
368
+ self._exit_stack = AsyncExitStack()
369
+ async with self._harness_spawn_guard():
370
+ self._sdk_agent = await self._exit_stack.enter_async_context(SdkAgent(cfg))
371
+ self._log.debug("Antigravity local harness started (model=%s)", self._effective_model())
372
+ except Exception as e:
373
+ await self._teardown()
374
+ raise RuntimeError(f"Failed to start Antigravity agent: {e}") from e
375
+
376
+ @contextlib.asynccontextmanager
377
+ async def _harness_spawn_guard(self) -> AsyncIterator[None]:
378
+ """Prepend ``_env_path_prepend`` onto ``os.environ['PATH']`` across a harness spawn.
379
+
380
+ The localharness ``subprocess.Popen`` inherits ``os.environ`` at spawn time and
381
+ the SDK exposes no env seam, so mock CLIs can only shadow the real ones by
382
+ mutating the process PATH across the harness context-entry (subprocess spawn +
383
+ session open). The mutation is serialized (process-wide lock) and restored in
384
+ ``finally`` — the spawned child keeps the env it started with, so the restore
385
+ never affects the live harness. The lock is taken even when no prepend dirs
386
+ were configured: a no-prepend spawn must still wait out any in-flight mutated-
387
+ PATH window, or its harness would inherit another task's mock dirs.
388
+ """
389
+ async with _harness_spawn_lock():
390
+ if not self._env_path_prepend:
391
+ yield
392
+ return
393
+ path_key = next((k for k in os.environ if k.upper() == "PATH"), "PATH")
394
+ original = os.environ.get(path_key)
395
+ os.environ[path_key] = os.pathsep.join([*self._env_path_prepend, original or ""])
396
+ self._log.debug("PATH prepend for harness spawn: %s", os.pathsep.join(self._env_path_prepend))
397
+ try:
398
+ yield
399
+ finally:
400
+ if original is None:
401
+ os.environ.pop(path_key, None)
402
+ else:
403
+ os.environ[path_key] = original
404
+
405
+ async def communicate(
406
+ self,
407
+ user_input: str,
408
+ *,
409
+ stream_callback: StreamCallback | None = None,
410
+ timeout: float | None = None,
411
+ max_turns: int | None = None,
412
+ ) -> TurnRecord:
413
+ """Send a message to the Antigravity agent and receive its response.
414
+
415
+ Drives one logical turn: ``conversation.send(prompt)`` then iterate
416
+ ``receive_steps()`` until the turn goes idle, mapping the Gemini step
417
+ stream onto the standardized event protocol.
418
+
419
+ Raises:
420
+ RuntimeError: If the agent is not started.
421
+ TurnTimeoutError: Timeout elapsed (partial TurnRecord on pending_turn).
422
+ AgentCrashError: SDK/harness failed mid-turn (same pending_turn contract).
423
+ """
424
+ if not self.working_directory or self._sdk_agent is None:
425
+ raise RuntimeError("Agent not started. Call start() first.")
426
+
427
+ assert self.config.type is not None, "AntigravityAgent requires AgentConfig.type before communicate()"
428
+
429
+ self._begin_turn()
430
+ turn_start_time = time.monotonic()
431
+ task_id = str(self.config.type)
432
+ model = self._effective_model()
433
+ collector = EventCollector()
434
+ emit = CompositeStreamCallback([c for c in (collector, stream_callback) if c is not None])
435
+ turn_id = f"antigravity-{self._iteration}"
436
+
437
+ state = _AntigravityTurnState(
438
+ agent=self,
439
+ emit=emit,
440
+ task_id=task_id,
441
+ turn_id=turn_id,
442
+ collector=collector,
443
+ user_input=user_input,
444
+ iteration=self._iteration,
445
+ model=model,
446
+ turn_start_time=turn_start_time,
447
+ )
448
+
449
+ try:
450
+ emit.on_event(AgentStartEvent(task_id=task_id, prompt=user_input, iteration=self._iteration, model=model))
451
+
452
+ def _on_turn_timeout() -> None:
453
+ state.timeout_hit = True
454
+
455
+ with ThreadedWatchdog(
456
+ timeout_seconds=timeout,
457
+ on_timeout=_on_turn_timeout,
458
+ asyncio_task_to_cancel=asyncio.current_task(),
459
+ label=f"Turn timeout ({timeout:g}s)" if timeout else "turn_timeout",
460
+ ):
461
+ emit.on_event(TurnStartEvent(task_id=task_id, turn_id=turn_id, model=model))
462
+ conversation = self._sdk_agent.conversation
463
+ try:
464
+ await conversation.send(user_input)
465
+ async for step in conversation.receive_steps():
466
+ state.process_step(step)
467
+ except asyncio.CancelledError:
468
+ if state.timeout_hit:
469
+ self._finalize_and_raise_timeout(state.finalize, timeout or 0)
470
+ raise
471
+ except Exception as e:
472
+ if state.timeout_hit:
473
+ self._finalize_and_raise_timeout(state.finalize, timeout or 0, cause=e)
474
+ self._finalize_and_raise_crash(
475
+ state.finalize, truncate_crash_message(f"Antigravity turn failed: {e!s}"), cause=e
476
+ )
477
+
478
+ if state.timeout_hit:
479
+ # Watchdog fired but the pump finished before the cancel landed.
480
+ assert timeout is not None
481
+ self._finalize_and_raise_timeout(state.finalize, timeout)
482
+ except (AgentCrashError, TurnTimeoutError):
483
+ raise
484
+ except asyncio.CancelledError:
485
+ if not state.finalized:
486
+ self._state = AgentState.ERROR
487
+ state.finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled")
488
+ raise
489
+ except Exception as e:
490
+ self._finalize_and_raise_crash(
491
+ state.finalize, truncate_crash_message(f"Antigravity turn failed: {e!s}"), cause=e
492
+ )
493
+
494
+ self._state = AgentState.WORKING
495
+ self._end_turn_ok()
496
+ state.finalize(AgentEndStatus.COMPLETED, crashed=False, crash_reason=None)
497
+ return collector.build_turn_record()
498
+
499
+ async def stop(self) -> None:
500
+ """Stop the agent and tear down the local harness session."""
501
+ await self._teardown()
502
+ self._mark_stopped()
503
+
504
+ async def kill(self) -> None:
505
+ """Force-terminate: cancel any in-flight turn, then tear down the harness."""
506
+ conversation = self._conversation_or_none()
507
+ if conversation is not None:
508
+ with contextlib.suppress(Exception):
509
+ await conversation.cancel()
510
+ await self.stop()
511
+
512
+ def kill_sync(self) -> None:
513
+ """Best-effort synchronous abort for the watchdog thread (cannot await).
514
+
515
+ Antigravity's cancel/disconnect are async-only, so the genuine teardown
516
+ happens via the asyncio-task cancel the watchdog also delivers (which
517
+ unwinds ``receive_steps``) and the subsequent ``stop()`` exit-stack close.
518
+ This hook only records intent.
519
+ """
520
+ self._state = AgentState.ERROR
521
+
522
+ def get_environment_info(self) -> dict[str, Any]:
523
+ """Record the resolved Gemini model + thinking level for auditability."""
524
+ return {
525
+ "antigravity_model": self._effective_model(),
526
+ "antigravity_thinking_level": self.config.thinking_level,
527
+ }
528
+
529
+ def _conversation_or_none(self) -> Any:
530
+ agent = self._sdk_agent
531
+ if agent is None:
532
+ return None
533
+ with contextlib.suppress(Exception):
534
+ return agent.conversation if agent.is_started else None
535
+ return None
536
+
537
+ async def _teardown(self) -> None:
538
+ """Close the SDK Agent context (reaps the localharness subprocess)."""
539
+ stack = self._exit_stack
540
+ self._exit_stack = None
541
+ self._sdk_agent = None
542
+ if stack is not None:
543
+ with contextlib.suppress(Exception):
544
+ await stack.aclose()
545
+
546
+
547
+ class _AntigravityTurnState:
548
+ """Per-turn mutable scratch for one ``AntigravityAgent.communicate`` call.
549
+
550
+ Maps the Gemini step stream onto the standardized event protocol and
551
+ reconstructs the assistant transcript. The same ``messages`` / ``commands``
552
+ accumulate live, so a mid-turn crash keeps the partial transcript (the
553
+ agent's shared crash kernel builds ``pending_turn`` from ``collector``).
554
+
555
+ Step-stream shape this consumes (observed): each ``step_index`` is yielded
556
+ repeatedly through ACTIVE -> DONE transitions; ``usage_metadata`` lands once
557
+ per generation on a DONE/terminal step (summing them == the turn total); a
558
+ tool call carries a stable ``id`` and its result is folded into expanded
559
+ ``args`` (``exit_code`` / ``combined_output`` / ``diff_block``) at DONE.
560
+ """
561
+
562
+ def __init__(
563
+ self,
564
+ *,
565
+ agent: AntigravityAgent,
566
+ emit: CompositeStreamCallback,
567
+ task_id: str,
568
+ turn_id: str,
569
+ collector: EventCollector,
570
+ user_input: str,
571
+ iteration: int,
572
+ model: str,
573
+ turn_start_time: float,
574
+ ) -> None:
575
+ self._agent = agent
576
+ self.emit = emit
577
+ self.task_id = task_id
578
+ self.turn_id = turn_id
579
+ self.collector = collector
580
+ self.user_input = user_input
581
+ self.iteration = iteration
582
+ self.model = model
583
+ self.turn_start_time = turn_start_time
584
+
585
+ self.timeout_hit = False
586
+ self.finalized = False
587
+
588
+ self.total_usage = TokenUsage()
589
+ self.messages: list[TranscriptMessage] = []
590
+ self.commands: list[CommandTelemetry] = []
591
+ self._output_parts: list[str] = []
592
+ self._assistant_turns = 0
593
+
594
+ # Tool tracking: emit ToolStart on first sight of an id; ToolEnd at DONE.
595
+ self._next_seq = 0
596
+ self._seen_tools: set[str] = set()
597
+ self._closed_tools: set[str] = set()
598
+ self._open_tools: dict[str, CommandTelemetry] = {}
599
+ # Raw arg keys present when a tool was first seen (its model-supplied
600
+ # inputs). Used at DONE to distinguish inputs from harness-appended
601
+ # result fields, whatever they're named for that tool.
602
+ self._tool_input_keys: dict[str, set[str]] = {}
603
+ # Content blocks accumulated since the last per-generation flush.
604
+ self._blocks: list[ContentBlock] = []
605
+
606
+ def process_step(self, step: Any) -> None:
607
+ """Route one streamed ``Step`` to events + transcript reconstruction."""
608
+ stype = _enum_value(step.type)
609
+ sstatus = _enum_value(step.status)
610
+ ssource = _enum_value(step.source)
611
+ starget = _enum_value(step.target)
612
+ done = sstatus in (_STATUS_DONE, _STATUS_ERROR)
613
+
614
+ # Stream visible assistant text deltas.
615
+ if step.content_delta and ssource == _SOURCE_MODEL and starget == _TARGET_USER and stype == _TYPE_TEXT_RESPONSE:
616
+ self.emit.on_event(TextChunkEvent(task_id=self.task_id, turn_id=self.turn_id, text=step.content_delta))
617
+
618
+ # Tool calls: ToolStart on first sight, ToolEnd when the owning step is DONE.
619
+ for call in step.tool_calls:
620
+ self._handle_tool_call(call, step, done, sstatus)
621
+
622
+ # Capture content blocks on the terminal transition of a step.
623
+ if done:
624
+ if stype == _TYPE_THINKING and step.thinking:
625
+ self._blocks.append(ContentBlock(block_type="thinking", sequence=0, thinking=step.thinking))
626
+ elif stype == _TYPE_TEXT_RESPONSE and step.content:
627
+ self._output_parts.append(step.content)
628
+ self._blocks.append(ContentBlock(block_type="text", sequence=0, text=step.content))
629
+
630
+ # Per-generation usage: fold into the turn total and cut an AssistantMessage.
631
+ if step.usage_metadata is not None:
632
+ gen = _to_token_usage(step.usage_metadata, self.model)
633
+ self.total_usage = self.total_usage + gen
634
+ self._flush_generation(gen, getattr(step.usage_metadata, "thoughts_token_count", 0) or 0)
635
+
636
+ def _handle_tool_call(self, call: Any, step: Any, done: bool, sstatus: Any) -> None:
637
+ raw_name = _enum_value(call.name)
638
+ cid = call.id or f"{raw_name}_{self._next_seq}"
639
+ if cid not in self._seen_tools:
640
+ self._seen_tools.add(cid)
641
+ seq = self._next_seq
642
+ self._next_seq += 1
643
+ tool_name = _ANTIGRAVITY_TO_CLAUDE_TOOL_MAP.get(raw_name, str(raw_name))
644
+ self._tool_input_keys[cid] = set(call.args)
645
+ now = datetime.now()
646
+ tel = CommandTelemetry(
647
+ tool_name=tool_name,
648
+ tool_id=cid,
649
+ timestamp=now,
650
+ parameters=self._params(tool_name, call.args, self._tool_input_keys[cid]),
651
+ sequence_number=seq,
652
+ execution_started_at=now,
653
+ )
654
+ self._open_tools[cid] = tel
655
+ self.emit.on_event(ToolStartEvent(task_id=self.task_id, turn_id=self.turn_id, tool=tel))
656
+
657
+ if done and cid in self._open_tools and cid not in self._closed_tools:
658
+ self._closed_tools.add(cid)
659
+ start_tel = self._open_tools[cid]
660
+ exit_code = call.args.get("exit_code")
661
+ errored = (sstatus == _STATUS_ERROR) or (exit_code not in (None, 0))
662
+ result_text = (
663
+ call.args.get("combined_output")
664
+ or call.args.get("diff_block")
665
+ or call.args.get("output")
666
+ or call.args.get("results")
667
+ or call.args.get("summary")
668
+ or step.content
669
+ or None
670
+ )
671
+ completed = datetime.now()
672
+ started = start_tel.execution_started_at or completed
673
+ end_tel = start_tel.model_copy(
674
+ update={
675
+ "parameters": self._params(start_tel.tool_name, call.args, self._tool_input_keys.get(cid)),
676
+ "result_status": "error" if errored else "success",
677
+ "result_summary": str(result_text) if result_text is not None else None,
678
+ "error_message": (step.error or "tool failed") if errored else None,
679
+ "execution_completed_at": completed,
680
+ "duration_ms": max((completed - started).total_seconds() * 1000.0, 0.0),
681
+ }
682
+ )
683
+ self.commands.append(end_tel)
684
+ self.emit.on_event(
685
+ ToolEndEvent(
686
+ task_id=self.task_id,
687
+ turn_id=self.turn_id,
688
+ tool=end_tel,
689
+ status=ToolEndStatus.ERROR if errored else ToolEndStatus.OK,
690
+ )
691
+ )
692
+ self._blocks.append(ContentBlock(block_type="tool_use", sequence=0, tool_use_id=cid))
693
+
694
+ @staticmethod
695
+ def _params(tool_name: str, args: dict[str, Any], input_keys: set[str] | None) -> dict[str, Any]:
696
+ """Model-supplied inputs only, renamed to canonical cross-agent keys.
697
+
698
+ A key is treated as a (dropped) result field when it is in the static
699
+ ``_RESULT_ARG_KEYS`` backstop OR — given the input-key snapshot taken at
700
+ tool start — it first appeared at DONE (harness-appended output, whatever
701
+ the tool names it). Surviving input keys are renamed via
702
+ ``_ANTIGRAVITY_ARG_RENAME`` so ``command_executed`` / reports key on the
703
+ same names (``command`` / ``path``) the Claude/Codex backends emit.
704
+ """
705
+ rename = _ANTIGRAVITY_ARG_RENAME.get(tool_name, {})
706
+ out: dict[str, Any] = {}
707
+ for k, v in args.items():
708
+ if k in _RESULT_ARG_KEYS:
709
+ continue
710
+ if input_keys is not None and k not in input_keys:
711
+ continue # appeared only at DONE → harness result payload
712
+ out[rename.get(k, k)] = v
713
+ return out
714
+
715
+ def _flush_generation(self, gen: TokenUsage, reasoning_tokens: int) -> None:
716
+ """Cut accumulated blocks into one AssistantMessage carrying this gen's tokens.
717
+
718
+ Keeping per-message token buckets summing to the turn total means the
719
+ EventCollector's reconciliation step books a zero residual.
720
+ """
721
+ if not self._blocks and gen.is_empty():
722
+ return
723
+ now = datetime.now()
724
+ for i, block in enumerate(self._blocks):
725
+ block.sequence = i
726
+ self.messages.append(
727
+ AssistantMessage(
728
+ started_at=now,
729
+ completed_at=now,
730
+ generation_duration_ms=0.0,
731
+ content_blocks=list(self._blocks),
732
+ tool_use_ids=[b.tool_use_id for b in self._blocks if b.block_type == "tool_use" and b.tool_use_id],
733
+ input_tokens=gen.uncached_input_tokens,
734
+ output_tokens=gen.output_tokens,
735
+ cache_creation_tokens=0,
736
+ cache_read_tokens=gen.cache_read_input_tokens,
737
+ reasoning_tokens=reasoning_tokens,
738
+ model=self.model,
739
+ )
740
+ )
741
+ self._assistant_turns += 1
742
+ self._blocks = []
743
+
744
+ def _agent_output(self) -> str:
745
+ if self._output_parts:
746
+ return "".join(self._output_parts)
747
+ with contextlib.suppress(Exception):
748
+ return self._agent._sdk_agent.conversation.last_response # type: ignore[union-attr]
749
+ return ""
750
+
751
+ def finalize(self, status: AgentEndStatus, *, crashed: bool = False, crash_reason: str | None = None) -> None:
752
+ """Close orphaned tools, flush leftover blocks, emit TurnEnd + AgentEnd.
753
+
754
+ Idempotent. On a crash, also builds the partial ``pending_turn`` from the
755
+ collector (the agent base's shared crash kernel).
756
+ """
757
+ if self.finalized:
758
+ return
759
+ self.finalized = True
760
+
761
+ # Force-close any tool that emitted ToolStart but never reached DONE.
762
+ for cid, tel in self._open_tools.items():
763
+ if cid in self._closed_tools:
764
+ continue
765
+ orphan = tel.model_copy(update={"result_status": "unknown", "execution_completed_at": datetime.now()})
766
+ self.emit.on_event(
767
+ ToolEndEvent(
768
+ task_id=self.task_id,
769
+ turn_id=self.turn_id,
770
+ tool=orphan,
771
+ status=ToolEndStatus.UNRESOLVED,
772
+ )
773
+ )
774
+
775
+ # Flush any trailing blocks not yet attached to a generation (no usage).
776
+ if self._blocks:
777
+ self._flush_generation(TokenUsage(), 0)
778
+
779
+ # AgentEndStatus and TurnEndStatus are parallel by value; convert directly
780
+ # (mirrors the Codex sibling) so an unmapped future member raises loudly
781
+ # instead of silently bucketing to COMPLETED.
782
+ turn_status = TurnEndStatus(status.value)
783
+
784
+ self.emit.on_event(
785
+ TurnEndEvent(
786
+ task_id=self.task_id,
787
+ turn_id=self.turn_id,
788
+ status=turn_status,
789
+ tokens=self.total_usage,
790
+ )
791
+ )
792
+ self.emit.on_event(
793
+ AgentEndEvent(
794
+ task_id=self.task_id,
795
+ status=status,
796
+ usage=self.total_usage,
797
+ iteration=self.iteration,
798
+ user_input=self.user_input,
799
+ agent_output=self._agent_output(),
800
+ model_used=self.model,
801
+ assistant_turn_count=self._assistant_turns,
802
+ messages=self.messages,
803
+ num_turns=self._assistant_turns,
804
+ crashed=crashed,
805
+ crash_reason=crash_reason,
806
+ duration_seconds=time.monotonic() - self.turn_start_time,
807
+ )
808
+ )
809
+
810
+ if crashed:
811
+ self._agent._capture_partial_turn(self.collector)