agentic-dev-loop 0.9.2__py3-none-any.whl → 0.10.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.
- agentic_dev_loop/core/config.py +58 -5
- agentic_dev_loop/core/profiles/__init__.py +178 -0
- agentic_dev_loop/core/profiles/data/dotnet-node.yaml +22 -0
- agentic_dev_loop/core/profiles/data/python.yaml +8 -0
- agentic_dev_loop/engine/cli.py +6 -0
- agentic_dev_loop/engine/graph.py +14 -0
- agentic_dev_loop/engine/hostboundary.py +3 -0
- agentic_dev_loop/engine/llm.py +72 -38
- agentic_dev_loop/engine/onboarding/__init__.py +6 -0
- agentic_dev_loop/engine/onboarding/checks.py +272 -0
- agentic_dev_loop/engine/onboarding/doctor.py +84 -0
- agentic_dev_loop/engine/onboarding/init.py +432 -0
- agentic_dev_loop/engine/onboarding/recipes/dotnet-node.yaml +85 -0
- agentic_dev_loop/engine/onboarding/recipes/python.yaml +57 -0
- agentic_dev_loop/engine/onboarding/recipes.py +77 -0
- agentic_dev_loop/engine/onboarding/templates/dev.agent.md +133 -0
- agentic_dev_loop/engine/onboarding/templates/env.example +11 -0
- agentic_dev_loop/engine/onboarding/templates/qa.agent.md +181 -0
- agentic_dev_loop/engine/onboarding/templates/review.agent.md +53 -0
- agentic_dev_loop/engine/onboarding/templates/trigger-agentic-dev-loop.sh +97 -0
- agentic_dev_loop/engine/onboarding/templates.py +62 -0
- agentic_dev_loop/engine/stages.py +14 -6
- {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/METADATA +81 -32
- {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/RECORD +28 -12
- {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/WHEEL +0 -0
- {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/entry_points.txt +0 -0
- {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/licenses/LICENSE +0 -0
- {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/top_level.txt +0 -0
agentic_dev_loop/core/config.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
import importlib.metadata
|
|
6
7
|
import re
|
|
7
8
|
import warnings
|
|
8
9
|
from pathlib import Path
|
|
@@ -396,9 +397,13 @@ class StepConfig(BaseModel):
|
|
|
396
397
|
terminal_timeout: float | None = Field(default=None, gt=0) # seconds, agent steps only (032)
|
|
397
398
|
finalize_nudge_turns: int | None = Field(default=None, gt=0) # agent steps only (035)
|
|
398
399
|
brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
|
|
399
|
-
# 047 (D7): per-step opt-in override; unset falls back to the
|
|
400
|
-
#
|
|
400
|
+
# 047 (D7) / 054 (D6): per-step opt-in override; unset falls back to the
|
|
401
|
+
# effective profile's value (the named `llms:` profile, else the `llm:`
|
|
402
|
+
# block), else reasoning is off. Full SDK literal, passthrough semantics.
|
|
401
403
|
reasoning_effort: ReasoningEffort | None = None
|
|
404
|
+
# 054 (D3): names a top-level `llms:` profile; the whole profile moves
|
|
405
|
+
# together (model, endpoint, key, effort default). None = the `llm:` block.
|
|
406
|
+
llm: str | None = None
|
|
402
407
|
custom_prompt: str | None = None # agent steps only (040); file path resolved at load
|
|
403
408
|
env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
|
|
404
409
|
setup: str | None = None # command or agent steps; runs fresh at each step entry (052)
|
|
@@ -486,6 +491,16 @@ class StepConfig(BaseModel):
|
|
|
486
491
|
f"step {self.name!r}: exactly one of 'agent', 'action', "
|
|
487
492
|
"'command', or 'brief_inputs' is required"
|
|
488
493
|
)
|
|
494
|
+
if self.llm is not None:
|
|
495
|
+
if not self.llm.strip():
|
|
496
|
+
raise ValueError(
|
|
497
|
+
f"step {self.name!r}: 'llm' must be a non-empty profile name"
|
|
498
|
+
)
|
|
499
|
+
if self.kind not in ("agent", "brief"):
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"step {self.name!r}: 'llm' names an LLM profile and is "
|
|
502
|
+
"only allowed on 'agent' or 'brief_inputs' steps"
|
|
503
|
+
)
|
|
489
504
|
if self.brief_inputs is not None:
|
|
490
505
|
if self.produces:
|
|
491
506
|
raise ValueError(
|
|
@@ -587,9 +602,9 @@ class LlmConfig(BaseModel):
|
|
|
587
602
|
base_url: str | None = None
|
|
588
603
|
api_key_env: str
|
|
589
604
|
timeout: float | None = None # omitted → opinionated no-timeout default
|
|
590
|
-
# 047 (D1): opt-in passthrough; unset means
|
|
591
|
-
#
|
|
592
|
-
#
|
|
605
|
+
# 047 (D1) / 054 (D6): opt-in passthrough; unset means reasoning is off
|
|
606
|
+
# (the engine forwards none and strips reasoning passback). The SDK
|
|
607
|
+
# "high" default never engages because build_llm always passes the value.
|
|
593
608
|
reasoning_effort: ReasoningEffort | None = None
|
|
594
609
|
|
|
595
610
|
|
|
@@ -606,6 +621,9 @@ class PipelineConfig(BaseModel):
|
|
|
606
621
|
extensions_dir: str = "~/.agentic-dev-loop/extensions"
|
|
607
622
|
env_allowlist: list[str] = []
|
|
608
623
|
llm: LlmConfig
|
|
624
|
+
# 054 (D1): named LLM profiles, each a strict LlmConfig. Optional and
|
|
625
|
+
# purely additive - the `llm:` block above remains the default.
|
|
626
|
+
llms: dict[str, LlmConfig] = {}
|
|
609
627
|
isolation: IsolationConfig | None = None # None = always-on defaults
|
|
610
628
|
state_dir: str = ".pr" # default relocated to ~/.agentic-dev-loop/runs/<hash>/ at load
|
|
611
629
|
secrets: dict[str, SecretSpec] = {} # ref name -> locator; values never appear here
|
|
@@ -630,6 +648,9 @@ class PipelineConfig(BaseModel):
|
|
|
630
648
|
# edit instead of raising. Same declared-path vocabulary as
|
|
631
649
|
# ``sync_excludes:``.
|
|
632
650
|
merge_on_conflict: list[str] = []
|
|
651
|
+
# 055: the toolchain profile resolved at load time. Kept on the validated
|
|
652
|
+
# config so the run records which profile supplied its defaults.
|
|
653
|
+
profile: str | None = None
|
|
633
654
|
|
|
634
655
|
@field_validator("deps_policy")
|
|
635
656
|
@classmethod
|
|
@@ -690,6 +711,23 @@ class PipelineConfig(BaseModel):
|
|
|
690
711
|
"enforces, or set 'deps_policy: open'")
|
|
691
712
|
return self
|
|
692
713
|
|
|
714
|
+
@model_validator(mode="after")
|
|
715
|
+
def _check_llm_profiles(self) -> "PipelineConfig":
|
|
716
|
+
"""054 (D5): the registry is additive and referential. A profile
|
|
717
|
+
literally named 'default' is rejected (the `llm:` block already is the
|
|
718
|
+
default), and any step naming an undeclared profile fails at load,
|
|
719
|
+
never mid-run."""
|
|
720
|
+
if "default" in self.llms:
|
|
721
|
+
raise ValueError(
|
|
722
|
+
"'llms' cannot declare a profile named 'default' - the "
|
|
723
|
+
"top-level 'llm:' block is already the default")
|
|
724
|
+
for step in self.steps:
|
|
725
|
+
if step.llm is not None and step.llm not in self.llms:
|
|
726
|
+
raise ValueError(
|
|
727
|
+
f"step {step.name!r}: unknown LLM profile {step.llm!r} - "
|
|
728
|
+
f"declared profiles: {sorted(self.llms)}")
|
|
729
|
+
return self
|
|
730
|
+
|
|
693
731
|
@model_validator(mode="after")
|
|
694
732
|
def _mounts_avoid_state_dir(self) -> "PipelineConfig":
|
|
695
733
|
"""BR-1.3: the host-only run-state dir is never a mount path.
|
|
@@ -858,6 +896,21 @@ def load_config(path: Path) -> PipelineConfig:
|
|
|
858
896
|
raise ValueError(
|
|
859
897
|
f"pipeline config must be a mapping, got {type(data).__name__}: {path}"
|
|
860
898
|
)
|
|
899
|
+
profile_ref = data.pop("profile", None)
|
|
900
|
+
if profile_ref is not None:
|
|
901
|
+
from agentic_dev_loop.core.profiles import (
|
|
902
|
+
ProfileError,
|
|
903
|
+
load_profile,
|
|
904
|
+
merge_profile,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
installed = importlib.metadata.version("agentic-dev-loop")
|
|
909
|
+
profile = load_profile(str(profile_ref), installed)
|
|
910
|
+
data = merge_profile(data, profile)
|
|
911
|
+
except ProfileError as exc:
|
|
912
|
+
raise ValueError(f"pipeline {path}: {exc}") from exc
|
|
913
|
+
data["profile"] = profile_ref
|
|
861
914
|
if "state_dir" not in data:
|
|
862
915
|
data["state_dir"] = default_state_dir(path)
|
|
863
916
|
cfg = PipelineConfig.model_validate(data)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Toolchain profiles: bundled data plus the load-time merge into a pipeline.
|
|
2
|
+
|
|
3
|
+
A profile supplies the environment facts every app on a toolchain shares
|
|
4
|
+
(isolation image, caches, premise files, gitignore lines, doctor probes).
|
|
5
|
+
Profiles never carry commands, gates, or steps - those live in
|
|
6
|
+
engine-resident recipes. The subpackage is intentionally dependency-light
|
|
7
|
+
(stdlib + pydantic + PyYAML) so ``core`` can resolve a profile at config
|
|
8
|
+
load time without importing the engine.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PremiseFile",
|
|
22
|
+
"Profile",
|
|
23
|
+
"ProfileError",
|
|
24
|
+
"ProfileIsolation",
|
|
25
|
+
"bundled_profile_names",
|
|
26
|
+
"check_engine_min",
|
|
27
|
+
"load_profile",
|
|
28
|
+
"merge_profile",
|
|
29
|
+
"parse_version",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+))?$")
|
|
33
|
+
_RANK = {"a": 0, "b": 1, "rc": 2}
|
|
34
|
+
_FINAL = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProfileError(ValueError):
|
|
38
|
+
"""Any profile load, parse, or compatibility failure."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PremiseFile(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
path: str
|
|
45
|
+
content: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProfileIsolation(BaseModel):
|
|
49
|
+
model_config = ConfigDict(extra="forbid")
|
|
50
|
+
|
|
51
|
+
image: str
|
|
52
|
+
seccomp_profile: str | None = None
|
|
53
|
+
cpus: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Profile(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid")
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
engine_min: str
|
|
61
|
+
description: str = ""
|
|
62
|
+
isolation: ProfileIsolation
|
|
63
|
+
caches: list[str] = Field(default_factory=list)
|
|
64
|
+
premise_files: list[PremiseFile] = Field(default_factory=list)
|
|
65
|
+
gitignore_additions: list[str] = Field(default_factory=list)
|
|
66
|
+
doctor_checks: list = Field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_version(text: str) -> tuple[int, int, int, int, int]:
|
|
70
|
+
"""Parse ``X.Y.Z[a|b|rcN]`` into a directly comparable 5-tuple.
|
|
71
|
+
|
|
72
|
+
Rank ordering is alpha < beta < rc < final; the prerelease number is 0
|
|
73
|
+
for a final release so ``1.0.0rc9 < 1.0.0``.
|
|
74
|
+
"""
|
|
75
|
+
match = _VERSION_RE.match(text.strip())
|
|
76
|
+
if match is None:
|
|
77
|
+
raise ProfileError(f"unparseable version: {text!r}")
|
|
78
|
+
major, minor, patch, pre, num = match.groups()
|
|
79
|
+
if pre is None:
|
|
80
|
+
return (int(major), int(minor), int(patch), _FINAL, 0)
|
|
81
|
+
return (int(major), int(minor), int(patch), _RANK[pre], int(num))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_engine_min(engine_min: str, installed: str,
|
|
85
|
+
latest: str | None = None) -> None:
|
|
86
|
+
"""Raise unless ``installed`` satisfies ``engine_min``.
|
|
87
|
+
|
|
88
|
+
``latest`` is substituted into the fix line by the caller (doctor); when
|
|
89
|
+
omitted the floor itself is used.
|
|
90
|
+
"""
|
|
91
|
+
if parse_version(installed) < parse_version(engine_min):
|
|
92
|
+
target = latest or engine_min
|
|
93
|
+
raise ProfileError(
|
|
94
|
+
f"profile requires engine >= {engine_min}; installed {installed}. "
|
|
95
|
+
f'Upgrade: pipx install --force "agentic-dev-loop=={target}"')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _data_dir():
|
|
99
|
+
return resources.files(__package__).joinpath("data")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def bundled_profile_names() -> list[str]:
|
|
103
|
+
return sorted(
|
|
104
|
+
entry.name[: -len(".yaml")]
|
|
105
|
+
for entry in _data_dir().iterdir()
|
|
106
|
+
if entry.name.endswith(".yaml")
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_yaml(text: str, source: str) -> dict:
|
|
111
|
+
try:
|
|
112
|
+
data = yaml.safe_load(text)
|
|
113
|
+
except yaml.YAMLError as exc:
|
|
114
|
+
raise ProfileError(f"profile {source!r}: invalid YAML: {exc}") from exc
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
raise ProfileError(f"profile {source!r} must be a mapping")
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _validate(data: dict, source: str) -> Profile:
|
|
121
|
+
try:
|
|
122
|
+
return Profile.model_validate(data)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
raise ProfileError(f"profile {source!r}: {exc}") from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_profile(reference: str, installed_version: str) -> Profile:
|
|
128
|
+
"""Load a bundled profile by name or a consumer profile by path.
|
|
129
|
+
|
|
130
|
+
Existence decides: a filesystem path that exists is read as a consumer
|
|
131
|
+
profile; anything else must name a bundled profile.
|
|
132
|
+
"""
|
|
133
|
+
candidate = Path(reference)
|
|
134
|
+
if candidate.is_file():
|
|
135
|
+
data = _read_yaml(candidate.read_text(encoding="utf-8"), reference)
|
|
136
|
+
profile = _validate(data, reference)
|
|
137
|
+
else:
|
|
138
|
+
names = bundled_profile_names()
|
|
139
|
+
if reference not in names:
|
|
140
|
+
raise ProfileError(
|
|
141
|
+
f"unknown profile '{reference}'; bundled profiles: "
|
|
142
|
+
+ ", ".join(names))
|
|
143
|
+
entry = _data_dir().joinpath(f"{reference}.yaml")
|
|
144
|
+
data = _read_yaml(entry.read_text(encoding="utf-8"), reference)
|
|
145
|
+
if data.get("name") != reference:
|
|
146
|
+
raise ProfileError(
|
|
147
|
+
f"profile {reference!r}: declared name {data.get('name')!r} "
|
|
148
|
+
f"does not match its filename")
|
|
149
|
+
profile = _validate(data, reference)
|
|
150
|
+
check_engine_min(profile.engine_min, installed_version)
|
|
151
|
+
return profile
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _dedupe(items: list[str]) -> list[str]:
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
out: list[str] = []
|
|
157
|
+
for item in items:
|
|
158
|
+
if item not in seen:
|
|
159
|
+
seen.add(item)
|
|
160
|
+
out.append(item)
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def merge_profile(data: dict, profile: Profile) -> dict:
|
|
165
|
+
"""Merge a profile into a pipeline dict; the pipeline wins.
|
|
166
|
+
|
|
167
|
+
Isolation merges key-wise (profile under, pipeline over). Caches union
|
|
168
|
+
profile-first, dedupe-preserving-order. Every other key is untouched.
|
|
169
|
+
"""
|
|
170
|
+
merged = dict(data)
|
|
171
|
+
profile_iso = profile.isolation.model_dump(exclude_none=True)
|
|
172
|
+
pipeline_iso = merged.get("isolation") or {}
|
|
173
|
+
if not isinstance(pipeline_iso, dict):
|
|
174
|
+
pipeline_iso = {}
|
|
175
|
+
merged["isolation"] = {**profile_iso, **pipeline_iso}
|
|
176
|
+
caches = _dedupe(list(profile.caches) + list(merged.get("caches") or []))
|
|
177
|
+
merged["caches"] = caches
|
|
178
|
+
return merged
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: dotnet-node
|
|
2
|
+
engine_min: 0.9.2
|
|
3
|
+
description: .NET backend with a Node.js frontend
|
|
4
|
+
isolation:
|
|
5
|
+
image: ghcr.io/lukedawilson/agentic-dev-loop/dev-dotnet@sha256:d15d1c6115fafc3d5f3bfb567338d61441081e5d7ff83d7770bc0b5f14f977fc
|
|
6
|
+
seccomp_profile: bundled:chromium-userns
|
|
7
|
+
cpus: 6
|
|
8
|
+
caches:
|
|
9
|
+
- .nuget-packages
|
|
10
|
+
premise_files:
|
|
11
|
+
- path: NuGet.Config
|
|
12
|
+
content: |
|
|
13
|
+
<configuration>
|
|
14
|
+
<config>
|
|
15
|
+
<add key="globalPackagesFolder" value=".nuget-packages" />
|
|
16
|
+
</config>
|
|
17
|
+
</configuration>
|
|
18
|
+
gitignore_additions:
|
|
19
|
+
- bin/
|
|
20
|
+
- obj/
|
|
21
|
+
- node_modules/
|
|
22
|
+
- .nuget-packages/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
name: python
|
|
2
|
+
engine_min: 0.9.2
|
|
3
|
+
description: Python project; the sandbox builds its own venv per run
|
|
4
|
+
isolation:
|
|
5
|
+
image: ghcr.io/lukedawilson/agentic-dev-loop/dev@sha256:890105209c127446caf20ce3dc05f0c0f5527e9d679e18cc0075e6b0ee917434
|
|
6
|
+
cpus: 2
|
|
7
|
+
gitignore_additions:
|
|
8
|
+
- .venv-sandbox/
|
agentic_dev_loop/engine/cli.py
CHANGED
|
@@ -165,6 +165,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
165
165
|
argv = list(argv)
|
|
166
166
|
if argv and argv[0] == "containers":
|
|
167
167
|
return _run_containers(argv[1:])
|
|
168
|
+
if argv and argv[0] == "init":
|
|
169
|
+
from .onboarding.init import run_init
|
|
170
|
+
return run_init(argv[1:])
|
|
171
|
+
if argv and argv[0] == "doctor":
|
|
172
|
+
from .onboarding.doctor import run_doctor
|
|
173
|
+
return run_doctor(argv[1:])
|
|
168
174
|
probe = argparse.ArgumentParser(add_help=False)
|
|
169
175
|
probe.add_argument("pipeline", type=Path, nargs="?")
|
|
170
176
|
_add_version_flag(probe)
|
agentic_dev_loop/engine/graph.py
CHANGED
|
@@ -177,11 +177,25 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
|
|
|
177
177
|
sandbox.close()
|
|
178
178
|
|
|
179
179
|
|
|
180
|
+
def _build_profile_llms(cfg) -> dict:
|
|
181
|
+
"""054 (D7): build the LLM for each profile referenced by any step,
|
|
182
|
+
eagerly, so a missing api_key_env fails at run start, not mid-run.
|
|
183
|
+
Declared-but-unreferenced profiles are shape-validated at load; their
|
|
184
|
+
keys are not checked here."""
|
|
185
|
+
referenced = {step.llm for step in cfg.steps if step.llm is not None}
|
|
186
|
+
return {name: build_llm(cfg.llms[name]) for name in sorted(referenced)}
|
|
187
|
+
|
|
188
|
+
|
|
180
189
|
def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
|
|
181
190
|
sandbox, resolved_secrets, tooling_paths) -> tuple[bool, int]:
|
|
182
191
|
export_root = getattr(sandbox, "export_root", None)
|
|
183
192
|
engine_allowlist = resolve_engine_allowlist(registry, args)
|
|
193
|
+
# 054 (D7): build the default LLM plus one per profile referenced by any
|
|
194
|
+
# step, eagerly, so a missing api_key_env for a referenced profile fails
|
|
195
|
+
# at run start, not mid-run.
|
|
196
|
+
profile_llms = _build_profile_llms(cfg)
|
|
184
197
|
runtime = Runtime(cfg=cfg, registry=registry, llm=build_llm(cfg.llm),
|
|
198
|
+
llms=profile_llms,
|
|
185
199
|
agents_dir=base / cfg.agents_dir, state_dir=state_dir,
|
|
186
200
|
workspace=Path.cwd(), sandbox=sandbox,
|
|
187
201
|
sandbox_cwd=Path(export_root) if export_root else None,
|
|
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
|
|
|
32
32
|
# the dogfood pipeline and the consumer repo use); the other tooling paths are config- or
|
|
33
33
|
# pipeline-file-relative.
|
|
34
34
|
TOOLING_DIR_NAMES = ("aidlc-docs", "tools")
|
|
35
|
+
TOOLING_FILE_NAMES = ("trigger-agentic-dev-loop.sh",
|
|
36
|
+
"agentic-dev-loop/.engine-version")
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
class HostBoundaryError(RuntimeError):
|
|
@@ -73,6 +75,7 @@ def tooling_rel_paths(cfg: "PipelineConfig", pipeline_path: str | Path,
|
|
|
73
75
|
(base / cfg.agents_dir).resolve(),
|
|
74
76
|
*((base / rel).resolve() for rel in cfg.additional_files),
|
|
75
77
|
*(ws / name for name in TOOLING_DIR_NAMES),
|
|
78
|
+
*(ws / name for name in TOOLING_FILE_NAMES),
|
|
76
79
|
]
|
|
77
80
|
rels = {p.relative_to(ws).as_posix() for p in candidates
|
|
78
81
|
if p.is_relative_to(ws)}
|
agentic_dev_loop/engine/llm.py
CHANGED
|
@@ -4,64 +4,98 @@ never fall back to another env var (no silent fallbacks)."""
|
|
|
4
4
|
import os
|
|
5
5
|
|
|
6
6
|
from openhands.sdk import LLM
|
|
7
|
-
from openhands.sdk.llm.utils import model_features as _model_features
|
|
8
7
|
|
|
9
8
|
from agentic_dev_loop.core.config import LlmConfig
|
|
10
9
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
_model_features.SEND_REASONING_CONTENT_MODELS.append(_DEEPSEEK_MODEL)
|
|
20
|
-
|
|
21
|
-
# Compat shim 2: the SDK splits a multi-tool-call turn into one assistant
|
|
22
|
-
# message per call (Agent.step) and carries reasoning_content only on the
|
|
23
|
-
# first; Message.to_chat_dict also drops empty-string reasoning. DeepSeek's
|
|
24
|
-
# thinking mode 400s any request whose history contains reasoning_content
|
|
25
|
-
# but omits it from an assistant tool-call message ("The reasoning_content
|
|
26
|
-
# in the thinking mode must be passed back to the API"). Repair the
|
|
27
|
-
# serialized history: an assistant tool-call message without its own
|
|
28
|
-
# reasoning borrows the turn's last reasoning (reset on user turns), and a
|
|
29
|
-
# present-but-empty reasoning is still written (field presence matters).
|
|
30
|
-
# Remove once the SDK distributes reasoning_content to every message.
|
|
10
|
+
# 054 (D8): reasoning passback is config-gated, never name-list-gated. The
|
|
11
|
+
# SDK gates reasoning_content passback on a closed model-name list
|
|
12
|
+
# (SEND_REASONING_CONTENT_MODELS) the engine never consults; instead each
|
|
13
|
+
# LLM instance carries the engine-resolved effort on
|
|
14
|
+
# ``_agentic_dev_loop_reasoning_effort`` and the serializer below gates
|
|
15
|
+
# passback on it. Gate on = the effort is set and not "none"; gate off =
|
|
16
|
+
# reasoning is stripped and, when history still carries reasoning_content,
|
|
17
|
+
# a clear engine error fires before the doomed request is sent (D12).
|
|
31
18
|
_ORIGINAL_FORMAT_MESSAGES = LLM.format_messages_for_llm
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reasoning_gate_on(llm: LLM) -> bool:
|
|
22
|
+
"""The engine-configured effort for this instance, if any. The SDK's own
|
|
23
|
+
``reasoning_effort`` field defaults to "high", so a direct (non-engine)
|
|
24
|
+
instance would read as gate-on; the dedicated attribute is only set by
|
|
25
|
+
``build_llm``, keeping absent effort = reasoning off."""
|
|
26
|
+
effort = getattr(llm, "_agentic_dev_loop_reasoning_effort", None)
|
|
27
|
+
return effort is not None and effort != "none"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reasoning_stripped_error(llm: LLM) -> RuntimeError:
|
|
31
|
+
"""054 (D12): gate off while the conversation still carries
|
|
32
|
+
reasoning_content - a request that strips it would 400 at the provider."""
|
|
33
|
+
effort = getattr(llm, "_agentic_dev_loop_reasoning_effort", None)
|
|
34
|
+
return RuntimeError(
|
|
35
|
+
f"model {llm.model!r} is configured with reasoning off "
|
|
36
|
+
f"(reasoning_effort={effort!r}) but the conversation history carries "
|
|
37
|
+
"reasoning_content that would be stripped from the request. Set "
|
|
38
|
+
"'reasoning_effort' (low/medium/high/xhigh) on the step or its llm "
|
|
39
|
+
"profile, use 'none' only where the provider honors it, or use a "
|
|
40
|
+
"non-thinking model."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
32
44
|
if not getattr(_ORIGINAL_FORMAT_MESSAGES, "_agentic_dev_loop_reasoning_repair", False):
|
|
33
|
-
def
|
|
45
|
+
def _config_gated_reasoning_serializer(self, messages: list) -> list[dict]:
|
|
46
|
+
"""The engine-owned reasoning passback serializer (054 D8).
|
|
47
|
+
|
|
48
|
+
Gate on: every assistant message that carries reasoning_content
|
|
49
|
+
writes it back (the SDK only does so for listed models and only on
|
|
50
|
+
the first message of a split tool-call turn), and a tool-call
|
|
51
|
+
message without its own reasoning borrows the turn's last reasoning
|
|
52
|
+
(reset on user turns). Gate off: reasoning is stripped from the
|
|
53
|
+
outgoing request and, if history still carries reasoning_content,
|
|
54
|
+
the D12 error fires instead of sending a doomed request.
|
|
55
|
+
"""
|
|
34
56
|
out = _ORIGINAL_FORMAT_MESSAGES(self, messages)
|
|
57
|
+
if not _reasoning_gate_on(self):
|
|
58
|
+
for d in out:
|
|
59
|
+
d.pop("reasoning_content", None)
|
|
60
|
+
if any(getattr(m, "reasoning_content", None) for m in messages):
|
|
61
|
+
raise _reasoning_stripped_error(self)
|
|
62
|
+
return out
|
|
63
|
+
|
|
35
64
|
last = None
|
|
36
65
|
for msg, d in zip(messages, out):
|
|
37
66
|
if msg.role == "user":
|
|
38
67
|
last = None
|
|
39
|
-
|
|
40
|
-
|
|
68
|
+
continue
|
|
69
|
+
if msg.role != "assistant":
|
|
70
|
+
continue
|
|
71
|
+
rc = getattr(msg, "reasoning_content", None)
|
|
72
|
+
if msg.tool_calls:
|
|
41
73
|
if rc is not None:
|
|
42
74
|
last = rc
|
|
43
75
|
if last is not None:
|
|
44
76
|
d["reasoning_content"] = last
|
|
77
|
+
elif rc:
|
|
78
|
+
d["reasoning_content"] = rc
|
|
45
79
|
return out
|
|
46
80
|
|
|
47
|
-
|
|
48
|
-
LLM.format_messages_for_llm =
|
|
81
|
+
_config_gated_reasoning_serializer._agentic_dev_loop_reasoning_repair = True
|
|
82
|
+
LLM.format_messages_for_llm = _config_gated_reasoning_serializer
|
|
49
83
|
|
|
50
84
|
|
|
51
85
|
def build_llm(cfg: LlmConfig, reasoning_effort: str | None = None) -> LLM:
|
|
52
86
|
api_key = os.environ.get(cfg.api_key_env)
|
|
53
87
|
if not api_key:
|
|
54
88
|
raise RuntimeError(f"{cfg.api_key_env} environment variable is required")
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
89
|
+
# 054 (D6): a step's value wins, else the effective profile's value, else
|
|
90
|
+
# off. reasoning_effort is always passed (None when unset) so the SDK
|
|
91
|
+
# "high" default cannot engage; absent effort = reasoning off.
|
|
58
92
|
effort = reasoning_effort if reasoning_effort is not None else cfg.reasoning_effort
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
93
|
+
llm = LLM(
|
|
94
|
+
model=cfg.model,
|
|
95
|
+
api_key=api_key,
|
|
96
|
+
base_url=cfg.base_url,
|
|
97
|
+
timeout=cfg.timeout, # policy: no LLM timeout unless explicitly set
|
|
98
|
+
reasoning_effort=effort,
|
|
99
|
+
)
|
|
100
|
+
llm._agentic_dev_loop_reasoning_effort = effort
|
|
101
|
+
return llm
|