snodo-engine 0.7.2__tar.gz

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 (43) hide show
  1. snodo_engine-0.7.2/PKG-INFO +19 -0
  2. snodo_engine-0.7.2/pyproject.toml +34 -0
  3. snodo_engine-0.7.2/setup.cfg +4 -0
  4. snodo_engine-0.7.2/src/snodo/coders/__init__.py +108 -0
  5. snodo_engine-0.7.2/src/snodo/coders/agy_adapter.py +38 -0
  6. snodo_engine-0.7.2/src/snodo/coders/anthropic_adapter.py +22 -0
  7. snodo_engine-0.7.2/src/snodo/coders/base.py +365 -0
  8. snodo_engine-0.7.2/src/snodo/coders/gemini_adapter.py +28 -0
  9. snodo_engine-0.7.2/src/snodo/coders/litellm.py +1439 -0
  10. snodo_engine-0.7.2/src/snodo/coders/mock.py +203 -0
  11. snodo_engine-0.7.2/src/snodo/coders/openai_adapter.py +16 -0
  12. snodo_engine-0.7.2/src/snodo/coders/opencode_adapter.py +327 -0
  13. snodo_engine-0.7.2/src/snodo/coders/opencode_cli_adapter.py +34 -0
  14. snodo_engine-0.7.2/src/snodo/coders/opencode_container.py +267 -0
  15. snodo_engine-0.7.2/src/snodo/coders/subprocess_adapter.py +219 -0
  16. snodo_engine-0.7.2/src/snodo/engine/__init__.py +0 -0
  17. snodo_engine-0.7.2/src/snodo/engine/closure.py +295 -0
  18. snodo_engine-0.7.2/src/snodo/engine/constraints.py +120 -0
  19. snodo_engine-0.7.2/src/snodo/engine/loop.py +1002 -0
  20. snodo_engine-0.7.2/src/snodo/engine/nodes/__init__.py +1 -0
  21. snodo_engine-0.7.2/src/snodo/engine/nodes/context.py +137 -0
  22. snodo_engine-0.7.2/src/snodo/engine/nodes/executor.py +168 -0
  23. snodo_engine-0.7.2/src/snodo/engine/nodes/governance.py +289 -0
  24. snodo_engine-0.7.2/src/snodo/engine/nodes/state.py +160 -0
  25. snodo_engine-0.7.2/src/snodo/engine/nodes/validation.py +563 -0
  26. snodo_engine-0.7.2/src/snodo/engine/nodes/writeback.py +597 -0
  27. snodo_engine-0.7.2/src/snodo/engine/policy.py +411 -0
  28. snodo_engine-0.7.2/src/snodo/engine/progress.py +71 -0
  29. snodo_engine-0.7.2/src/snodo/engine/state.py +105 -0
  30. snodo_engine-0.7.2/src/snodo/engine/validators.py +90 -0
  31. snodo_engine-0.7.2/src/snodo/validators/__init__.py +6 -0
  32. snodo_engine-0.7.2/src/snodo/validators/acceptance.py +164 -0
  33. snodo_engine-0.7.2/src/snodo/validators/context.py +65 -0
  34. snodo_engine-0.7.2/src/snodo/validators/llm_validator.py +1044 -0
  35. snodo_engine-0.7.2/src/snodo/validators/protocol_adherence.py +331 -0
  36. snodo_engine-0.7.2/src/snodo/validators/quality.py +376 -0
  37. snodo_engine-0.7.2/src/snodo/validators/registry.py +52 -0
  38. snodo_engine-0.7.2/src/snodo/validators/runner.py +429 -0
  39. snodo_engine-0.7.2/src/snodo_engine.egg-info/PKG-INFO +19 -0
  40. snodo_engine-0.7.2/src/snodo_engine.egg-info/SOURCES.txt +41 -0
  41. snodo_engine-0.7.2/src/snodo_engine.egg-info/dependency_links.txt +1 -0
  42. snodo_engine-0.7.2/src/snodo_engine.egg-info/requires.txt +10 -0
  43. snodo_engine-0.7.2/src/snodo_engine.egg-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: snodo-engine
3
+ Version: 0.7.2
4
+ Summary: Snodo engine — protocol loop graph, coders, and validators
5
+ Author-email: The Snodo Authors <noreply@snodo.dev>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://snodo.dev
8
+ Project-URL: Repository, https://github.com/snodo-dev/snodo
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: snodo-core==0.7.2
11
+ Requires-Dist: snodo-tools==0.7.2
12
+ Requires-Dist: snodo-foundation==0.7.2
13
+ Requires-Dist: pydantic>=2.12.0
14
+ Requires-Dist: pyyaml>=6.0
15
+ Requires-Dist: langchain>=0.3.0
16
+ Requires-Dist: langchain-core>=0.3.0
17
+ Requires-Dist: langchain-community>=0.4.0
18
+ Requires-Dist: langgraph>=0.2.0
19
+ Requires-Dist: litellm>=1.80.0
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snodo-engine"
7
+ version = "0.7.2"
8
+ description = "Snodo engine — protocol loop graph, coders, and validators"
9
+ requires-python = ">=3.12"
10
+ license = { text = "Apache-2.0" }
11
+ authors = [{ name = "The Snodo Authors", email = "noreply@snodo.dev" }]
12
+ dependencies = [
13
+ "snodo-core==0.7.2",
14
+ "snodo-tools==0.7.2",
15
+ "snodo-foundation==0.7.2",
16
+ "pydantic>=2.12.0",
17
+ "pyyaml>=6.0",
18
+ "langchain>=0.3.0",
19
+ "langchain-core>=0.3.0",
20
+ "langchain-community>=0.4.0",
21
+ "langgraph>=0.2.0",
22
+ "litellm>=1.80.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://snodo.dev"
27
+ Repository = "https://github.com/snodo-dev/snodo"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+ namespaces = true
32
+
33
+ [tool.setuptools.package-data]
34
+ snodo = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,108 @@
1
+ """Coder adapter registry.
2
+
3
+ FILE: snodo/coders/__init__.py
4
+
5
+ Registry pattern for pluggable coder backends.
6
+ """
7
+
8
+ from typing import Any, Dict, Optional, Type
9
+
10
+ from snodo.coders.base import (
11
+ CoderAdapter,
12
+ AdapterError as AdapterError,
13
+ LLMCallError as LLMCallError,
14
+ ParseError as ParseError,
15
+ TurnBudgetExhausted as TurnBudgetExhausted,
16
+ )
17
+ from snodo.coders.litellm import LiteLLMAdapter
18
+ from snodo.coders.mock import MockAdapter
19
+ from snodo.coders.openai_adapter import OpenAIAdapter
20
+ from snodo.coders.anthropic_adapter import AnthropicAdapter
21
+ from snodo.coders.gemini_adapter import GeminiAdapter
22
+ from snodo.coders.opencode_adapter import OpenCodeAdapter
23
+ from snodo.coders.opencode_cli_adapter import OpenCodeCLIAdapter
24
+ from snodo.coders.agy_adapter import AGYAdapter
25
+ from snodo.infrastructure.config import DEFAULT_MODEL
26
+
27
+ # Backward-compatible aliases
28
+ BasicCoderAdapter = LiteLLMAdapter
29
+ MockCoderAdapter = MockAdapter
30
+
31
+ # Registry of available coder backends
32
+ CODER_REGISTRY: Dict[str, Type[CoderAdapter]] = {
33
+ "litellm": LiteLLMAdapter,
34
+ "mock": MockAdapter,
35
+ "openai": OpenAIAdapter,
36
+ "anthropic": AnthropicAdapter,
37
+ "gemini": GeminiAdapter,
38
+ "opencode": OpenCodeAdapter,
39
+ "opencode-cli": OpenCodeCLIAdapter,
40
+ "agy": AGYAdapter,
41
+ }
42
+
43
+
44
+ def resolve_coder_name(
45
+ model: str = DEFAULT_MODEL,
46
+ mode_coder: Optional[str] = None,
47
+ cli_coder: Optional[str] = None,
48
+ use_mock: bool = False,
49
+ ) -> str:
50
+ """Resolve the coder registry name following precedence:
51
+ 1. Explicit mock flag (use_mock / --mock)
52
+ 2. Explicit CLI choice (cli_coder / --coder)
53
+ 3. Protocol mode choice (mode_coder / mode.coder)
54
+ 4. Model string prefix mapping (opencode-cli/, opencode/, agy/, gpt/o1/o3, claude, gemini)
55
+ 5. Default fallback ('litellm')
56
+ """
57
+ if use_mock:
58
+ return "mock"
59
+ if cli_coder:
60
+ return cli_coder
61
+ if mode_coder:
62
+ return mode_coder
63
+ if model:
64
+ if model.startswith("opencode-cli/"):
65
+ return "opencode-cli"
66
+ if model.startswith("opencode/"):
67
+ return "opencode"
68
+ if model.startswith("agy/"):
69
+ return "agy"
70
+ if model.startswith(("gpt", "o1", "o3")):
71
+ return "openai"
72
+ if model.startswith("claude"):
73
+ return "anthropic"
74
+ if model.startswith(("gemini", "google/")):
75
+ return "gemini"
76
+ return "litellm"
77
+
78
+
79
+ def resolve_adapter_class(model: str) -> Type[CoderAdapter]:
80
+ """Resolve the appropriate coder adapter class for a model string.
81
+
82
+ Args:
83
+ model: Model identifier (e.g., "claude-sonnet-4-20250514", "gpt-4o")
84
+
85
+ Returns:
86
+ CoderAdapter subclass best suited for the model.
87
+ """
88
+ coder_name = resolve_coder_name(model=model)
89
+ return CODER_REGISTRY[coder_name]
90
+
91
+
92
+ def get_coder(name: str, **config: Any) -> CoderAdapter:
93
+ """Get a coder adapter by registry name.
94
+
95
+ Args:
96
+ name: Registered coder name (e.g., "litellm", "mock", "opencode-cli")
97
+ **config: Configuration passed to the adapter constructor
98
+
99
+ Returns:
100
+ Initialized CoderAdapter instance
101
+
102
+ Raises:
103
+ KeyError: If name is not in the registry
104
+ """
105
+ if name not in CODER_REGISTRY:
106
+ available = ", ".join(sorted(CODER_REGISTRY.keys()))
107
+ raise KeyError(f"Unknown coder '{name}'. Available: {available}")
108
+ return CODER_REGISTRY[name](**config)
@@ -0,0 +1,38 @@
1
+ """Antigravity CLI (agy) coder adapter — shells `agy -p` on the host.
2
+
3
+ FILE: snodo/coders/agy_adapter.py
4
+
5
+ Runs Antigravity CLI directly on the host machine via SubprocessCoderAdapter:
6
+
7
+ agy -p <prompt> --dangerously-skip-permissions --add-dir <workspace> [--model <model>]
8
+
9
+ Changes are read back from the working tree via git diff (agy writes files in place).
10
+ """
11
+
12
+ from typing import List
13
+
14
+ from snodo.coders.subprocess_adapter import SubprocessCoderAdapter
15
+
16
+
17
+ class AGYAdapter(SubprocessCoderAdapter):
18
+ """Coder adapter backed by Antigravity CLI (agy)."""
19
+
20
+ coder_name: str = "agy"
21
+ binary: str = "agy"
22
+ model_prefix: str = "agy/"
23
+ install_hint: str = (
24
+ "Install agy: https://antigravity.google/docs/cli"
25
+ )
26
+
27
+ def _build_argv(self, prompt: str, project_root: str, model: str) -> List[str]:
28
+ argv = [
29
+ "agy",
30
+ "-p",
31
+ prompt,
32
+ "--dangerously-skip-permissions",
33
+ "--add-dir",
34
+ project_root,
35
+ ]
36
+ if model:
37
+ argv.extend(["--model", model])
38
+ return argv
@@ -0,0 +1,22 @@
1
+ """Anthropic coder adapter.
2
+
3
+ Inherits the base LiteLLMAdapter. LiteLLM 1.83.7 internally transforms
4
+ OpenAI-format role:"tool" messages to Anthropic's tool_result blocks,
5
+ so _call_llm_with_tools is inherited unchanged.
6
+ """
7
+
8
+ from snodo.coders.litellm import LiteLLMAdapter
9
+
10
+
11
+ class AnthropicAdapter(LiteLLMAdapter):
12
+ """Coder adapter for Anthropic Claude models.
13
+
14
+ LiteLLM transforms our OpenAI-format tool messages internally:
15
+ {"role": "tool", "tool_call_id": "...", "content": "..."}
16
+ → {"role": "user", "content": [{"type": "tool_result",
17
+ "tool_use_id": "...", "content": "..."}]}
18
+
19
+ No override of _call_llm_with_tools needed.
20
+ """
21
+
22
+ TRUNCATION_REASONS: set[str] = {"length", "max_tokens", "MAX_TOKENS"}
@@ -0,0 +1,365 @@
1
+ """Base coder adapter interface and exceptions.
2
+
3
+ FILE: snodo/coders/base.py
4
+
5
+ Defines the CoderAdapter ABC that all coder backends implement, plus the
6
+ InPlaceCoderAdapter base for adapters that write to the working tree
7
+ directly (opencode and similar) instead of through WorkspaceMCP.
8
+ """
9
+
10
+ import logging
11
+ import time
12
+ from abc import ABC, abstractmethod
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional
15
+
16
+ from snodo.core.interfaces import Coder, CodeArtifact, TaskSpec
17
+
18
+ _logger = logging.getLogger(__name__)
19
+
20
+
21
+ # CoderAdapter is the canonical name for the coder interface.
22
+ # It's an alias for the core Coder ABC to provide a clearer name
23
+ # in the adapter context while maintaining interface compatibility.
24
+ CoderAdapter = Coder
25
+
26
+
27
+ class AdapterError(Exception):
28
+ """Base exception for adapter operations."""
29
+
30
+
31
+ class LLMCallError(AdapterError):
32
+ """LLM API call failed."""
33
+
34
+
35
+ class ParseError(AdapterError):
36
+ """Failed to parse LLM output."""
37
+
38
+
39
+ class TurnBudgetExhausted(AdapterError):
40
+ """The coder consumed its full turn budget without submitting files.
41
+
42
+ A bounded, anticipated outcome, not a crash: the coder ran out of turns
43
+ before calling ``submit_files`` with a deliverable file set. Distinct from
44
+ :class:`ParseError` (an unparseable response) so the engine can report it
45
+ under its own halt outcome instead of masking it as ``internal_error``.
46
+ """
47
+
48
+
49
+ class SnodoMutationError(AdapterError):
50
+ """An in-place-writing coder modified protected .snodo/ state.
51
+
52
+ Adapters that write to the working tree directly (opencode and similar)
53
+ bypass WorkspaceMCP, so the .snodo/ boundary cannot be enforced at the
54
+ tool surface. The mutation is detected by the base class after the coder
55
+ runs and raised here; it is NOT undone — the tree is left for operator
56
+ inspection and the engine surfaces this as a blocker halt (Fixes #52).
57
+ """
58
+
59
+ def __init__(self, paths: List[str]):
60
+ self.paths = list(paths)
61
+ super().__init__(
62
+ "Coder modified protected .snodo/ paths: "
63
+ + ", ".join(paths)
64
+ + ". .snodo/ holds the protocol and governance state the agent is "
65
+ "judged by and is not part of the coder's write surface."
66
+ )
67
+
68
+
69
+ class InPlaceCoderAdapter(Coder, ABC):
70
+ """Base for coders that write to the working tree in place.
71
+
72
+ opencode and similar tools do not route file operations through
73
+ WorkspaceMCP — they write files directly on the host, so
74
+ ``skip_workspace_write`` is True and the .snodo/ boundary cannot be
75
+ enforced at the tool surface (ADR 026).
76
+
77
+ Enforcement therefore lives here, in the base class, so it holds for
78
+ every in-place adapter and cannot be forgotten by a future one: the
79
+ adapter snapshots .snodo/ before the coder runs and, if anything under
80
+ it changed afterwards, raises :class:`SnodoMutationError` so the engine
81
+ can surface a blocker halt and record the attempt in the audit trail. A
82
+ .snodo/ mutation must never be silently absent from the artifact report
83
+ or the audit trail.
84
+
85
+ Subclasses implement :meth:`_implement_in_place` and must set
86
+ ``self._workspace`` to the directory the coder writes into.
87
+ """
88
+
89
+ #: The coder writes to the working tree directly, not via WorkspaceMCP.
90
+ skip_workspace_write: bool = True
91
+ #: The coder commits its own changes; the engine does not stage them.
92
+ skip_engine_commit: bool = True
93
+ #: Canonical coder identifier (e.g. "agy", "opencode-cli", "opencode").
94
+ coder_name: str
95
+
96
+ _workspace: Path
97
+ last_commit_reason: Optional[str] = None
98
+ _head_before_run: Optional[str] = None
99
+
100
+ @property
101
+ def workspace(self) -> Path:
102
+ """The granted containment boundary (workspace or task worktree) for in-place execution."""
103
+ return self._workspace
104
+
105
+ def implement(self, spec: TaskSpec) -> CodeArtifact:
106
+ """Run the coder, then refuse any .snodo/ mutation it made.
107
+
108
+ The snapshot window is the coder call itself: the engine's own
109
+ bookkeeping under .snodo/ (audit log, sessions, state.json) happens
110
+ outside this window, so a change detected here is attributable to the
111
+ coder.
112
+ """
113
+ self.last_commit_reason = None
114
+ before = self._snapshot_snodo()
115
+ # Record HEAD before dispatch so we can diff against it afterwards.
116
+ # This distinguishes "coder committed its work" from "coder did nothing"
117
+ # and handles multiple commits by the coder (ADR 035, #199).
118
+ self._head_before_run = self._record_head_before_run()
119
+ t0 = time.perf_counter()
120
+ artifact = self._implement_in_place(spec)
121
+ duration_ms = round((time.perf_counter() - t0) * 1000, 2)
122
+ changed = self._changed_snodo_paths(before)
123
+ if changed:
124
+ raise SnodoMutationError(sorted(changed))
125
+ # Commit what the coder wrote so post-execute validators that review
126
+ # ``git diff HEAD~1..HEAD`` (llm_validator / acceptance "## Code
127
+ # Change") see THIS change, not the previous commit. Owned here, in
128
+ # the base class, so no in-place adapter can drift (the same property
129
+ # that made the .snodo/ guard hold automatically).
130
+ self._commit_changes()
131
+
132
+ # Record attribution for in-place coder runs (Fixes #69).
133
+ # In-place coders make no litellm calls, so without this attribution
134
+ # record, runs are indistinguishable in state.json from zero-cost runs.
135
+ coder_name = getattr(self, "coder_name", None)
136
+ if not coder_name:
137
+ raise AttributeError(
138
+ f"{self.__class__.__name__} does not declare coder_name. "
139
+ "InPlaceCoderAdapter subclasses must explicitly declare coder_name."
140
+ )
141
+ model_str = getattr(self, "model", "") or ""
142
+
143
+ if artifact and hasattr(artifact, "metadata") and isinstance(artifact.metadata, dict):
144
+ artifact.metadata["duration_ms"] = duration_ms
145
+ artifact.metadata["coder"] = coder_name
146
+ artifact.metadata["model"] = model_str
147
+
148
+ try:
149
+ from snodo.infrastructure.usage_tracker import record_inplace_coder_run
150
+ job_id = spec.project_context.get("job_id", "") if spec and spec.project_context else ""
151
+ task_id = spec.project_context.get("task_id", "") if spec and spec.project_context else ""
152
+ record_inplace_coder_run(
153
+ coder=coder_name,
154
+ model=model_str,
155
+ duration_ms=duration_ms,
156
+ job_id=job_id,
157
+ task_id=task_id,
158
+ )
159
+ except Exception as e:
160
+ _logger.debug("Failed to record inplace coder run: %s", e)
161
+
162
+ return artifact
163
+
164
+ def _record_head_before_run(self) -> Optional[str]:
165
+ """Record the current HEAD sha before running the coder.
166
+
167
+ Returns the commit sha or None if the repo cannot be opened.
168
+ """
169
+ from git import Repo, GitCommandError
170
+
171
+ try:
172
+ repo = Repo(str(self._workspace), search_parent_directories=True)
173
+ return repo.head.commit.hexsha
174
+ except (GitCommandError, Exception):
175
+ return None
176
+
177
+ @abstractmethod
178
+ def _implement_in_place(self, spec: TaskSpec) -> CodeArtifact:
179
+ """Run the coder against the workspace and return its CodeArtifact."""
180
+
181
+ def _read_changes_from_disk(self) -> list:
182
+ """Detect changed files via git in the workspace.
183
+
184
+ In-place coders edit files directly in the working tree, so the
185
+ on-disk state at ``self._workspace`` is the source of truth for both
186
+ the returned CodeArtifact and the committed review channel. Returns
187
+ entries in the same ``{file, status}`` format ``_diff_to_artifact``
188
+ expects.
189
+
190
+ For external CLI coders that commit their own changes (e.g. agy with
191
+ ``--dangerously-skip-permissions``, opencode run), the working tree
192
+ may already be committed when this runs. If so, diff against the
193
+ recorded HEAD sha from before the coder ran (``self._head_before_run``).
194
+ This correctly handles: (a) coder committed its work, (b) coder made
195
+ several commits, (c) coder did nothing (empty diff, not a false positive
196
+ from main's last commit).
197
+ """
198
+ from git import Repo, GitCommandError
199
+
200
+ try:
201
+ repo = Repo(str(self._workspace), search_parent_directories=True)
202
+ except (GitCommandError, Exception) as exc:
203
+ _logger.warning("git readback: cannot open repo at %s: %s", self._workspace, exc)
204
+ return []
205
+
206
+ changed: dict[str, str] = {}
207
+
208
+ try:
209
+ # Unstaged changes (modified / deleted / added in working tree)
210
+ for d in repo.index.diff(None):
211
+ path = d.b_path or d.a_path
212
+ if path:
213
+ if d.change_type == "D":
214
+ changed[path] = "deleted"
215
+ else:
216
+ changed[path] = d.change_type
217
+
218
+ # Staged changes
219
+ for d in repo.index.diff("HEAD"):
220
+ path = d.b_path or d.a_path
221
+ if path and path not in changed:
222
+ changed[path] = d.change_type
223
+
224
+ # Untracked files (new files the coder created)
225
+ for path in repo.untracked_files:
226
+ changed[path] = "added"
227
+
228
+ # If no unstaged/staged/untracked changes found, check if the
229
+ # coder already committed (external CLI coders like agy or
230
+ # opencode run with --dangerously-skip-permissions). Diff against
231
+ # the recorded HEAD sha from before the coder ran to capture all
232
+ # commits the coder made, and avoid false positives when the coder
233
+ # did nothing.
234
+ if not changed and self._head_before_run:
235
+ try:
236
+ base_commit = repo.commit(self._head_before_run)
237
+ head_commit = repo.head.commit
238
+ # If HEAD moved since before the run, diff base..HEAD
239
+ if base_commit.hexsha != head_commit.hexsha:
240
+ for d in base_commit.diff(head_commit):
241
+ path = d.b_path or d.a_path
242
+ if path and path not in changed:
243
+ if d.change_type == "D":
244
+ changed[path] = "deleted"
245
+ else:
246
+ changed[path] = d.change_type
247
+ except (GitCommandError, ValueError):
248
+ # Commit lookup or diff failed - that's OK
249
+ pass
250
+
251
+ except Exception as exc:
252
+ _logger.warning("git readback: diff failed: %s", exc)
253
+ return []
254
+
255
+ entries = [{"file": path, "status": status} for path, status in changed.items()]
256
+
257
+ _logger.debug("git readback: %d changed files", len(entries))
258
+ return entries
259
+
260
+ def _commit_changes(self) -> None:
261
+ """Stage + commit the working-tree changes with an explicit identity.
262
+
263
+ In-place coders write files directly and never commit, so without
264
+ this HEAD would not move and post-execute validators that read
265
+ ``read_diff_between_refs -> HEAD~1..HEAD`` would review the previous
266
+ commit — or an empty diff — instead of the produced change. Owning
267
+ the commit here, in the base class, makes it a structural property of
268
+ every in-place adapter: the git review channel and the returned
269
+ CodeArtifact cannot diverge (the same reasoning that made the
270
+ .snodo/ guard hold automatically, ADR 027).
271
+
272
+ Non-fatal on failure — the working tree still holds the change — but
273
+ the post-execute diff would then be empty. Failure reasons are stored in
274
+ ``self.last_commit_reason`` for diagnostic reporting.
275
+ """
276
+ from git import Repo, GitCommandError
277
+
278
+ try:
279
+ repo = Repo(str(self._workspace), search_parent_directories=True)
280
+ except Exception as exc:
281
+ self.last_commit_reason = f"cannot_open_repo: {exc}"
282
+ _logger.warning(
283
+ "git readback: cannot open repo at %s: %s", self._workspace, exc
284
+ )
285
+ return
286
+
287
+ try:
288
+ repo.git.add(
289
+ "-A", "--",
290
+ ".",
291
+ ":(exclude).snodo", ":(exclude).snodo/**",
292
+ # keep coder-created virtualenvs / caches / build junk out of
293
+ # the committed diff (else review + extract_patch see MBs of it)
294
+ ":(exclude,glob)**/venv/**", ":(exclude,glob)**/.venv/**",
295
+ ":(exclude,glob)**/.venv_test/**", ":(exclude,glob)**/env/**",
296
+ ":(exclude,glob)**/__pycache__/**", ":(exclude,glob)**/*.egg-info/**",
297
+ ":(exclude,glob)**/node_modules/**", ":(exclude,glob)**/.tox/**",
298
+ ":(exclude,glob)**/.pytest_cache/**", ":(exclude,glob)**/.mypy_cache/**",
299
+ )
300
+ except GitCommandError as exc:
301
+ self.last_commit_reason = f"git_add_failed: {exc}"
302
+ _logger.warning("git add failed (post-validation diff may be empty): %s", exc)
303
+ return
304
+
305
+ # Nothing staged → nothing to commit (the coder made no changes).
306
+ try:
307
+ repo.git.diff("--cached", "--quiet")
308
+ except GitCommandError:
309
+ pass # rc != 0 → staged changes exist
310
+ else:
311
+ self.last_commit_reason = "nothing_staged"
312
+ _logger.warning("git add staged nothing (coder produced no changes)")
313
+ return
314
+
315
+ try:
316
+ # Identity via env (not repo config): the SWE-bench workspace is a
317
+ # detached checkout with no configured git user, and per-commit
318
+ # identity must not persist in a shared repo.
319
+ repo.git.commit(
320
+ "-q", "-m", "coder: apply changes",
321
+ env={
322
+ "GIT_AUTHOR_NAME": "snodo-coder",
323
+ "GIT_AUTHOR_EMAIL": "coder@snodo.exp",
324
+ "GIT_COMMITTER_NAME": "snodo-coder",
325
+ "GIT_COMMITTER_EMAIL": "coder@snodo.exp",
326
+ },
327
+ )
328
+ self.last_commit_reason = None
329
+ except GitCommandError as exc:
330
+ self.last_commit_reason = f"git_commit_failed: {exc}"
331
+ _logger.warning(
332
+ "coder commit failed (post-validation diff may be empty): %s", exc
333
+ )
334
+
335
+ def _snapshot_snodo(self) -> Dict[str, object]:
336
+ """Snapshot the .snodo/ directory contents under the workspace.
337
+
338
+ Because .snodo/ is normally gitignored (snodo init ignores it), git
339
+ readback cannot see a mutation there; a filesystem snapshot is the
340
+ only reliable detector. Content is compared, not mtime.
341
+ """
342
+ root = self._workspace
343
+ snodo_dir = root / ".snodo"
344
+ snap: Dict[str, object] = {}
345
+ if not snodo_dir.is_dir():
346
+ return snap
347
+ for path in sorted(snodo_dir.rglob("*")):
348
+ rel = path.relative_to(root).as_posix()
349
+ if path.is_dir():
350
+ snap[rel] = ("dir",)
351
+ elif path.is_file():
352
+ try:
353
+ snap[rel] = (path.stat().st_size, path.read_bytes())
354
+ except OSError:
355
+ snap[rel] = ("unreadable",)
356
+ return snap
357
+
358
+ def _changed_snodo_paths(self, before: Dict[str, object]) -> List[str]:
359
+ """Return relative paths under .snodo/ that changed vs *before*."""
360
+ after = self._snapshot_snodo()
361
+ return [
362
+ rel
363
+ for rel in set(before) | set(after)
364
+ if before.get(rel) != after.get(rel)
365
+ ]
@@ -0,0 +1,28 @@
1
+ """Gemini coder adapter.
2
+
3
+ Inherits the base LiteLLMAdapter. LiteLLM 1.83.7 internally transforms
4
+ OpenAI-format role:"tool" messages to Gemini's functionResponse parts,
5
+ so _call_llm_with_tools is inherited unchanged.
6
+
7
+ Verified transformation (litellm_core_utils/prompt_templates/factory.py):
8
+ {"role": "tool", "tool_call_id": "call_abc", "content": "..."}
9
+ → {"function_response": {"name": "read_file", "response": {"content": "..."}}}
10
+
11
+ The tool_call_id must match the assistant message's tool_calls[].id,
12
+ which our base _call_llm_with_tools already does.
13
+ """
14
+
15
+ from snodo.coders.litellm import LiteLLMAdapter
16
+
17
+
18
+ class GeminiAdapter(LiteLLMAdapter):
19
+ """Coder adapter for Google Gemini models (gemini/*, google/gemini-*).
20
+
21
+ LiteLLM transforms our OpenAI-format tool messages internally:
22
+ {"role": "tool", "tool_call_id": "...", "content": "..."}
23
+ → {"function_response": {"name": "...", "response": {"content": "..."}}}
24
+
25
+ No override of _call_llm_with_tools needed.
26
+ """
27
+
28
+ TRUNCATION_REASONS: set[str] = {"length", "max_tokens", "MAX_TOKENS"}