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.
- snodo_engine-0.7.2/PKG-INFO +19 -0
- snodo_engine-0.7.2/pyproject.toml +34 -0
- snodo_engine-0.7.2/setup.cfg +4 -0
- snodo_engine-0.7.2/src/snodo/coders/__init__.py +108 -0
- snodo_engine-0.7.2/src/snodo/coders/agy_adapter.py +38 -0
- snodo_engine-0.7.2/src/snodo/coders/anthropic_adapter.py +22 -0
- snodo_engine-0.7.2/src/snodo/coders/base.py +365 -0
- snodo_engine-0.7.2/src/snodo/coders/gemini_adapter.py +28 -0
- snodo_engine-0.7.2/src/snodo/coders/litellm.py +1439 -0
- snodo_engine-0.7.2/src/snodo/coders/mock.py +203 -0
- snodo_engine-0.7.2/src/snodo/coders/openai_adapter.py +16 -0
- snodo_engine-0.7.2/src/snodo/coders/opencode_adapter.py +327 -0
- snodo_engine-0.7.2/src/snodo/coders/opencode_cli_adapter.py +34 -0
- snodo_engine-0.7.2/src/snodo/coders/opencode_container.py +267 -0
- snodo_engine-0.7.2/src/snodo/coders/subprocess_adapter.py +219 -0
- snodo_engine-0.7.2/src/snodo/engine/__init__.py +0 -0
- snodo_engine-0.7.2/src/snodo/engine/closure.py +295 -0
- snodo_engine-0.7.2/src/snodo/engine/constraints.py +120 -0
- snodo_engine-0.7.2/src/snodo/engine/loop.py +1002 -0
- snodo_engine-0.7.2/src/snodo/engine/nodes/__init__.py +1 -0
- snodo_engine-0.7.2/src/snodo/engine/nodes/context.py +137 -0
- snodo_engine-0.7.2/src/snodo/engine/nodes/executor.py +168 -0
- snodo_engine-0.7.2/src/snodo/engine/nodes/governance.py +289 -0
- snodo_engine-0.7.2/src/snodo/engine/nodes/state.py +160 -0
- snodo_engine-0.7.2/src/snodo/engine/nodes/validation.py +563 -0
- snodo_engine-0.7.2/src/snodo/engine/nodes/writeback.py +597 -0
- snodo_engine-0.7.2/src/snodo/engine/policy.py +411 -0
- snodo_engine-0.7.2/src/snodo/engine/progress.py +71 -0
- snodo_engine-0.7.2/src/snodo/engine/state.py +105 -0
- snodo_engine-0.7.2/src/snodo/engine/validators.py +90 -0
- snodo_engine-0.7.2/src/snodo/validators/__init__.py +6 -0
- snodo_engine-0.7.2/src/snodo/validators/acceptance.py +164 -0
- snodo_engine-0.7.2/src/snodo/validators/context.py +65 -0
- snodo_engine-0.7.2/src/snodo/validators/llm_validator.py +1044 -0
- snodo_engine-0.7.2/src/snodo/validators/protocol_adherence.py +331 -0
- snodo_engine-0.7.2/src/snodo/validators/quality.py +376 -0
- snodo_engine-0.7.2/src/snodo/validators/registry.py +52 -0
- snodo_engine-0.7.2/src/snodo/validators/runner.py +429 -0
- snodo_engine-0.7.2/src/snodo_engine.egg-info/PKG-INFO +19 -0
- snodo_engine-0.7.2/src/snodo_engine.egg-info/SOURCES.txt +41 -0
- snodo_engine-0.7.2/src/snodo_engine.egg-info/dependency_links.txt +1 -0
- snodo_engine-0.7.2/src/snodo_engine.egg-info/requires.txt +10 -0
- 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,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"}
|