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.
Files changed (28) hide show
  1. agentic_dev_loop/core/config.py +58 -5
  2. agentic_dev_loop/core/profiles/__init__.py +178 -0
  3. agentic_dev_loop/core/profiles/data/dotnet-node.yaml +22 -0
  4. agentic_dev_loop/core/profiles/data/python.yaml +8 -0
  5. agentic_dev_loop/engine/cli.py +6 -0
  6. agentic_dev_loop/engine/graph.py +14 -0
  7. agentic_dev_loop/engine/hostboundary.py +3 -0
  8. agentic_dev_loop/engine/llm.py +72 -38
  9. agentic_dev_loop/engine/onboarding/__init__.py +6 -0
  10. agentic_dev_loop/engine/onboarding/checks.py +272 -0
  11. agentic_dev_loop/engine/onboarding/doctor.py +84 -0
  12. agentic_dev_loop/engine/onboarding/init.py +432 -0
  13. agentic_dev_loop/engine/onboarding/recipes/dotnet-node.yaml +85 -0
  14. agentic_dev_loop/engine/onboarding/recipes/python.yaml +57 -0
  15. agentic_dev_loop/engine/onboarding/recipes.py +77 -0
  16. agentic_dev_loop/engine/onboarding/templates/dev.agent.md +133 -0
  17. agentic_dev_loop/engine/onboarding/templates/env.example +11 -0
  18. agentic_dev_loop/engine/onboarding/templates/qa.agent.md +181 -0
  19. agentic_dev_loop/engine/onboarding/templates/review.agent.md +53 -0
  20. agentic_dev_loop/engine/onboarding/templates/trigger-agentic-dev-loop.sh +97 -0
  21. agentic_dev_loop/engine/onboarding/templates.py +62 -0
  22. agentic_dev_loop/engine/stages.py +14 -6
  23. {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/METADATA +81 -32
  24. {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/RECORD +28 -12
  25. {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/WHEEL +0 -0
  26. {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/entry_points.txt +0 -0
  27. {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/licenses/LICENSE +0 -0
  28. {agentic_dev_loop-0.9.2.dist-info → agentic_dev_loop-0.10.2.dist-info}/top_level.txt +0 -0
@@ -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 llm block,
400
- # then the SDK default. Full SDK literal, passthrough semantics.
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 the field is not passed at
591
- # all, so the SDK default ("high") applies and today's behavior is
592
- # byte-identical. No default may move without an observed run (D6).
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/
@@ -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)
@@ -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)}
@@ -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
- # Compat shim: OpenHands SDK 1.17 gates reasoning_content passback on a closed
12
- # model-name list (SEND_REASONING_CONTENT_MODELS) that does not know
13
- # deepseek-v4-pro. DeepSeek's thinking mode 400s on any multi-turn request
14
- # whose history omits prior reasoning_content. Unlike force_string_serializer,
15
- # the SDK exposes no instance-level override, so register the model here.
16
- # Remove once the SDK lists the model or adds an override.
17
- _DEEPSEEK_MODEL = "deepseek-v4-pro"
18
- if _DEEPSEEK_MODEL not in _model_features.SEND_REASONING_CONTENT_MODELS:
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 _repair_reasoning_passback(self, messages: list) -> list[dict]:
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
- if msg.role == "assistant" and msg.tool_calls:
40
- rc = getattr(msg, "reasoning_content", None)
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
- _repair_reasoning_passback._agentic_dev_loop_reasoning_repair = True
48
- LLM.format_messages_for_llm = _repair_reasoning_passback
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
- # 047 (D2/D7): an explicit per-step override wins, else the llm block's
56
- # value applies, else the field is omitted entirely and the SDK default
57
- # ("high") is in force. The reasoning_content shims above stay untouched.
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
- kwargs = {
60
- "model": cfg.model,
61
- "api_key": api_key,
62
- "base_url": cfg.base_url,
63
- "timeout": cfg.timeout, # policy: no LLM timeout unless explicitly set
64
- }
65
- if effort is not None:
66
- kwargs["reasoning_effort"] = effort
67
- return LLM(**kwargs)
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
@@ -0,0 +1,6 @@
1
+ """Consumer onboarding: init, doctor, recipes, and templates.
2
+
3
+ These modules are engine concerns (they render pipelines and probe the host);
4
+ the only core dependency is ``agentic_dev_loop.core.profiles``, the allowed
5
+ engineering direction.
6
+ """