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,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)
|