coderouter-cli 2.11.0__py3-none-any.whl → 2.11.1__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.
@@ -53,11 +53,16 @@ from coderouter.translation.anthropic import (
53
53
  AnthropicResponse,
54
54
  AnthropicStreamEvent,
55
55
  )
56
- from coderouter.translation.convert import (
57
- stream_anthropic_to_chat_chunks,
58
- to_anthropic_request,
59
- to_chat_response,
60
- )
56
+
57
+ # NOTE: `coderouter.translation.convert` is intentionally NOT imported here
58
+ # at module scope (see H-7). The import chain is:
59
+ # translation/__init__.py -> convert.py -> adapters/__init__.py
60
+ # -> registry.py -> anthropic_native.py -> translation.convert (again)
61
+ # A top-level import here closes that cycle and makes standalone imports of
62
+ # `coderouter.translation.*` fail with a partial-initialization ImportError
63
+ # unless `coderouter.adapters` happened to be imported first. The three
64
+ # symbols used from `convert` are imported lazily inside `generate()` /
65
+ # `stream()` instead, which breaks the cycle without changing behavior.
61
66
 
62
67
  logger = get_logger(__name__)
63
68
 
@@ -210,6 +215,12 @@ class AnthropicAdapter(BaseAdapter):
210
215
  ``to_chat_response``. The ``coderouter_provider`` tag is preserved
211
216
  on both sides.
212
217
  """
218
+ # Local import to avoid a circular import at module load time (H-7):
219
+ # convert.py transitively imports coderouter.adapters, which imports
220
+ # this module. Importing convert.py here (call time, not load time)
221
+ # keeps `coderouter.translation.*` importable standalone.
222
+ from coderouter.translation.convert import to_anthropic_request, to_chat_response
223
+
213
224
  anth_req = to_anthropic_request(request)
214
225
  anth_resp = await self.generate_anthropic(anth_req, overrides=overrides)
215
226
  chat_resp = to_chat_response(anth_resp)
@@ -241,6 +252,13 @@ class AnthropicAdapter(BaseAdapter):
241
252
  - Anthropic ``event: error`` → translator raises
242
253
  AdapterError(retryable=False), same treatment as above.
243
254
  """
255
+ # Local import to avoid a circular import at module load time (H-7);
256
+ # see the matching comment in generate() above.
257
+ from coderouter.translation.convert import (
258
+ stream_anthropic_to_chat_chunks,
259
+ to_anthropic_request,
260
+ )
261
+
244
262
  anth_req = to_anthropic_request(request)
245
263
  events = self.stream_anthropic(anth_req, overrides=overrides)
246
264
  async for chunk in stream_anthropic_to_chat_chunks(events, provider_name=self.name):
coderouter/cli.py CHANGED
@@ -624,6 +624,24 @@ def _resolve_config_path(explicit: str | None) -> Path:
624
624
  return candidates[-1]
625
625
 
626
626
 
627
+ def _write_verdict_line(*, write: bool, files_written: int) -> None:
628
+ """Print the one-line "did we touch the disk?" verdict.
629
+
630
+ Called on **every** exit path of :func:`_run_apply_or_dry_run`.
631
+ The v1.x regression this guards against: an ``--apply`` run that
632
+ silently rewrote providers.yaml while the summary said "already
633
+ applied" and printed neither a diff nor a ``Backup:`` line. Making
634
+ the statement unconditional means no future early-return can go
635
+ quiet about a write again.
636
+ """
637
+ if not write:
638
+ print(" 0 files written (dry-run).")
639
+ elif files_written == 0:
640
+ print(" 0 files written — no file contents changed.")
641
+ else:
642
+ print(f" {files_written} file(s) written.")
643
+
644
+
627
645
  def _run_apply_or_dry_run(
628
646
  *,
629
647
  report: object,
@@ -655,9 +673,13 @@ def _run_apply_or_dry_run(
655
673
  )
656
674
  except MissingDependencyError as exc:
657
675
  print(f"doctor --apply: {exc}", file=sys.stderr)
676
+ _write_verdict_line(write=write, files_written=0)
658
677
  return 1
659
678
  except DoctorApplyError as exc:
679
+ # Every merge happens before the first byte is written, so an
680
+ # abort here always leaves the disk untouched.
660
681
  print(f"doctor --apply: {exc}", file=sys.stderr)
682
+ _write_verdict_line(write=write, files_written=0)
661
683
  return 1
662
684
 
663
685
  label = "Apply" if write else "Dry-run"
@@ -680,6 +702,8 @@ def _run_apply_or_dry_run(
680
702
  f" All {result.no_op_patches} patch(es) already applied "
681
703
  f"— providers.yaml is up to date."
682
704
  )
705
+ _write_verdict_line(write=write, files_written=len(result.written_paths))
706
+ _print_reformat_notice(result, write=write)
683
707
  return 0
684
708
 
685
709
  print(
@@ -694,16 +718,41 @@ def _run_apply_or_dry_run(
694
718
  print()
695
719
  print(diff, end="" if diff.endswith("\n") else "\n")
696
720
 
721
+ _write_verdict_line(write=write, files_written=len(result.written_paths))
697
722
  if write:
698
723
  for orig, bak in result.backups.items():
699
724
  print(f" Backup: {orig} → {bak}")
700
725
  else:
726
+ _print_reformat_notice(result, write=write)
701
727
  print()
702
728
  print(" (dry-run — no files were modified. Re-run with --apply to write.)")
703
729
 
704
730
  return 0
705
731
 
706
732
 
733
+ def _print_reformat_notice(result: object, *, write: bool) -> None:
734
+ """Dry-run-only heads-up about cosmetic re-serialization deltas.
735
+
736
+ ``reformat_only`` holds targets whose re-dump differs from disk
737
+ without any patch having changed a value — today that is explicit
738
+ ``key: null`` scalars, which ruamel re-emits as empty scalars.
739
+ These bytes are never written, so the note is purely so an operator
740
+ comparing "the diff I expected" with "the file I have" is not
741
+ surprised. Suppressed under ``--apply``, where nothing about them
742
+ is actionable.
743
+ """
744
+ reformat = getattr(result, "reformat_only", None) or {}
745
+ if write or not reformat:
746
+ return
747
+ print(
748
+ f" note: {len(reformat)} file(s) would re-serialize with cosmetic "
749
+ "differences (e.g. explicit `null` → empty scalar). These are NOT "
750
+ "written — --apply only rewrites files whose values actually changed."
751
+ )
752
+ for path in sorted(reformat):
753
+ print(f" - {path}")
754
+
755
+
707
756
 
708
757
 
709
758
  def _run_audit(args: argparse.Namespace) -> int:
@@ -7,6 +7,17 @@ Search order (first hit wins):
7
7
  4. ~/.coderouter/providers.yaml
8
8
 
9
9
  Secrets are resolved by reading the env var named by `api_key_env`.
10
+
11
+ .. note::
12
+ **[Unreleased] deprecation notice (planned v2.12.0):** step 3 above
13
+ (implicit CWD discovery) is slated to become opt-in, gated behind
14
+ ``CODEROUTER_ALLOW_CWD_CONFIG``. Landing that change without warning
15
+ first would silently break anyone currently relying on "run from the
16
+ directory with providers.yaml in it" with no diagnostic. Until that
17
+ lands, :func:`load_config` emits a one-time ``cwd-config-loaded``
18
+ warning whenever step 3 is the one that actually resolved — never
19
+ when an explicit ``--config`` / ``CODEROUTER_CONFIG`` path was used,
20
+ even if it happens to point at the same file.
10
21
  """
11
22
 
12
23
  from __future__ import annotations
@@ -17,6 +28,14 @@ from pathlib import Path
17
28
  import yaml
18
29
 
19
30
  from coderouter.config.schemas import CodeRouterConfig
31
+ from coderouter.logging import get_logger
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ # Guards the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning
36
+ # (see module docstring) so a process that calls load_config() repeatedly
37
+ # (tests, hot-reload, multiple create_app() calls) only logs it once.
38
+ _cwd_config_warning_emitted = False
20
39
 
21
40
 
22
41
  def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
@@ -30,6 +49,51 @@ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
30
49
  return paths
31
50
 
32
51
 
52
+ def _warn_if_cwd_config(
53
+ chosen: Path, *, explicit: str | os.PathLike[str] | None
54
+ ) -> None:
55
+ """Emit the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning.
56
+
57
+ Only fires when ``chosen`` was resolved via the *implicit* CWD-search
58
+ step. Compares ``chosen`` against the *literal* explicit / env
59
+ candidate paths (not merely whether those inputs were set) so that:
60
+
61
+ * an explicit ``--config`` (or ``CODEROUTER_CONFIG``) path that
62
+ happens to coincide with ``./providers.yaml`` stays quiet — an
63
+ explicit choice, however coincidental, is not the thing being
64
+ deprecated; but
65
+ * an explicit path/env var that was set but did NOT resolve (file
66
+ missing, so the search fell through to CWD) still warns, because
67
+ the CWD step is what actually served the config in that case.
68
+ """
69
+ global _cwd_config_warning_emitted
70
+ if _cwd_config_warning_emitted:
71
+ return
72
+ cwd_path = Path.cwd() / "providers.yaml"
73
+ if chosen != cwd_path:
74
+ return
75
+ if explicit and Path(explicit) == chosen:
76
+ return
77
+ if (env_path := os.environ.get("CODEROUTER_CONFIG")) and Path(env_path) == chosen:
78
+ return
79
+
80
+ _cwd_config_warning_emitted = True
81
+ logger.warning(
82
+ "cwd-config-loaded",
83
+ extra={
84
+ "path": str(chosen),
85
+ "hint": (
86
+ f"loaded providers.yaml from the current working "
87
+ f"directory ({chosen}). Starting in v2.12.0, this "
88
+ "implicit CWD discovery becomes opt-in (gated behind "
89
+ "CODEROUTER_ALLOW_CWD_CONFIG) — pass --config or set "
90
+ "CODEROUTER_CONFIG to pin the file explicitly and avoid "
91
+ "a startup break when that lands."
92
+ ),
93
+ },
94
+ )
95
+
96
+
33
97
  def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
34
98
  """Load providers.yaml + apply ALLOW_PAID env override."""
35
99
  candidates = _candidate_paths(path)
@@ -40,6 +104,7 @@ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
40
104
  f"providers.yaml not found. Searched:\n {searched}\n"
41
105
  f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
42
106
  )
107
+ _warn_if_cwd_config(chosen, explicit=path)
43
108
 
44
109
  with chosen.open("r", encoding="utf-8") as f:
45
110
  raw = yaml.safe_load(f) or {}
@@ -26,6 +26,10 @@ from pydantic import (
26
26
  model_validator,
27
27
  )
28
28
 
29
+ from coderouter.logging import get_logger
30
+
31
+ logger = get_logger(__name__)
32
+
29
33
 
30
34
  class Capabilities(BaseModel):
31
35
  """Capability flags per provider (plan.md §2.5)."""
@@ -350,6 +354,17 @@ class AgentCliConfig(BaseModel):
350
354
  return self
351
355
 
352
356
 
357
+ # [Unreleased]/planned v2.12.0: patterns used by ProviderConfig's
358
+ # ``_warn_restart_command_shell_syntax`` validator to flag
359
+ # ``restart_command`` values that rely on shell syntax not honored by
360
+ # the planned ``shlex.split`` + ``shell=False`` dispatch (see that
361
+ # validator's docstring and the field description below for the full
362
+ # rationale). Compiled once at module scope since every ProviderConfig
363
+ # validation reuses them.
364
+ _RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
365
+ _RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
366
+
367
+
353
368
  class ProviderConfig(BaseModel):
354
369
  """A single provider entry from providers.yaml.
355
370
 
@@ -497,15 +512,82 @@ class ProviderConfig(BaseModel):
497
512
  restart_command: str | None = Field(
498
513
  default=None,
499
514
  description=(
500
- "v2.0-J (Self-healing): shell command to restart this "
501
- "provider's backend process. Examples: 'ollama serve', "
515
+ "v2.0-J (Self-healing): command to restart this provider's "
516
+ "backend process. Examples: 'ollama serve', "
502
517
  "'open -a LM\\ Studio'. Only executed when the profile's "
503
518
  "backend_health_action is 'exclude' and the provider "
504
519
  "transitions to UNHEALTHY. Unset = no automatic restart "
505
- "(recovery probe still runs, waiting for manual restart)."
520
+ "(recovery probe still runs, waiting for manual restart). "
521
+ "\n\n"
522
+ "[Unreleased]/planned v2.12.0: currently run as "
523
+ "``subprocess.run(command, shell=True)`` (coderouter/guards/"
524
+ "self_healing.py), so shell syntax works today — pipelines "
525
+ "(``pkill ollama && ollama serve``), ``~`` expansion, and "
526
+ "leading env-var assignments (``OLLAMA_HOST=0.0.0.0 ollama "
527
+ "serve``) are all honored. A future minor switches this to "
528
+ "``shlex.split(command)`` + ``shell=False`` (argv dispatch, "
529
+ "no shell in between), which does NOT support any of the "
530
+ "above — ``&&`` becomes a literal argument to the first "
531
+ "command instead of a separator, ``~/...`` paths are not "
532
+ "expanded, and ``FOO=bar cmd`` fails to exec. If your "
533
+ "command needs shell features, wrap it in a small script "
534
+ "and point restart_command at that script instead, e.g. "
535
+ "``restart_command: /path/to/restart-ollama.sh``."
506
536
  ),
507
537
  )
508
538
 
539
+ @model_validator(mode="after")
540
+ def _warn_restart_command_shell_syntax(self) -> ProviderConfig:
541
+ """[Unreleased]/planned v2.12.0 breaking-change heads-up (warning only).
542
+
543
+ ``coderouter/guards/self_healing.py`` currently runs
544
+ ``restart_command`` via ``subprocess.run(command, shell=True)``.
545
+ The planned v2.12.0 change switches that to
546
+ ``shlex.split(command)`` + ``shell=False`` — a shell-syntax
547
+ ``restart_command`` that works fine today would then either
548
+ crash (``FileNotFoundError`` for ``~/...`` paths or an
549
+ ``ENV=val cmd`` prefix) or, worse, silently misbehave (``&&`` /
550
+ ``||`` / ``|`` / ``;`` / redirects becoming literal argv tokens
551
+ instead of shell operators — a provider "restart" that quietly
552
+ does the wrong thing is worse than one that errors loudly).
553
+
554
+ Deliberately never raises: this validator does not enforce
555
+ anything, it only warns ahead of the wire change so operators
556
+ can migrate ``restart_command`` to a plain argv (or a wrapper
557
+ script) before v2.12.0 lands, instead of discovering the break
558
+ the next time a provider actually goes UNHEALTHY.
559
+ """
560
+ cmd = self.restart_command
561
+ if not cmd:
562
+ return self
563
+ stripped = cmd.strip()
564
+ reasons: list[str] = []
565
+ if _RESTART_COMMAND_SHELL_META_RE.search(cmd):
566
+ reasons.append("shell metacharacter (&&, ||, |, ;, >, <, $(...), or `...`)")
567
+ if stripped.startswith("~/"):
568
+ reasons.append("leading '~/' path (no shell expansion under shell=False)")
569
+ if _RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
570
+ reasons.append("leading environment-variable assignment (FOO=bar ...)")
571
+ if reasons:
572
+ logger.warning(
573
+ "restart-command-shell-syntax",
574
+ extra={
575
+ "provider": self.name,
576
+ "restart_command": cmd,
577
+ "reasons": reasons,
578
+ "hint": (
579
+ "restart_command currently runs via "
580
+ "subprocess.run(..., shell=True); a planned "
581
+ "v2.12.0 change switches to shlex.split(...) + "
582
+ "shell=False, which does not support this "
583
+ "command's shell syntax. Rewrite it as plain "
584
+ "argv, or wrap it in a script and point "
585
+ "restart_command at that script."
586
+ ),
587
+ },
588
+ )
589
+ return self
590
+
509
591
  @model_validator(mode="after")
510
592
  def _check_output_filters_known(self) -> ProviderConfig:
511
593
  """v1.0-A: fail at config-load on a typo'd filter name.
coderouter/doctor.py CHANGED
@@ -321,17 +321,32 @@ def _patch_model_capabilities_yaml(*, match: str, kind: str, key: str, value: bo
321
321
 
322
322
 
323
323
  def _patch_providers_yaml_output_filters(provider_name: str, filters: list[str]) -> str:
324
- """v1.0-A: Emit a providers.yaml patch adding/extending ``output_filters``.
325
-
326
- Lists the filters verbatim so copy-paste yields a valid YAML list.
327
- The comment block above the stanza hints that this is additive with
328
- any existing filter chain — users with a bespoke chain should merge
329
- rather than replace.
324
+ """v1.0-A: Emit a providers.yaml patch setting the full ``output_filters`` chain.
325
+
326
+ ``filters`` must already be the **complete** post-fix chain — the
327
+ provider's existing filters in their existing order, plus whatever
328
+ the probe found missing appended at the end. Callers build that
329
+ union; see :func:`_union_output_filters`.
330
+
331
+ Why the complete chain and not just the additions: ``--apply``
332
+ merges this patch with ``deep_merge_dicts``, which replaces lists
333
+ wholesale (documented contract, pinned by
334
+ ``test_deep_merge_replaces_lists_wholesale``). Emitting only the
335
+ missing filters would therefore *delete* every filter the operator
336
+ already had — including ``strip_tool_call_xml`` /
337
+ ``repair_byte_fallback``, which this probe never recommends and so
338
+ could never add back. Keeping "patch YAML == the desired end state"
339
+ also means the copy-paste path and the ``--apply`` path agree, and
340
+ the ``--dry-run`` diff reads as the literal final value.
330
341
  """
331
342
  items = "\n".join(f" - {f}" for f in filters)
332
343
  return (
333
344
  "# providers.yaml — update the entry for "
334
- f"{provider_name!r} (merge if a chain already exists):\n"
345
+ f"{provider_name!r}. This is the COMPLETE chain (existing filters "
346
+ "plus the additions) —\n"
347
+ "# it replaces `output_filters` wholesale rather than appending "
348
+ "to it. Order is significant:\n"
349
+ "# filters run left to right.\n"
335
350
  "providers:\n"
336
351
  f" - name: {provider_name}\n"
337
352
  " # ... existing fields ...\n"
@@ -340,6 +355,27 @@ def _patch_providers_yaml_output_filters(provider_name: str, filters: list[str])
340
355
  )
341
356
 
342
357
 
358
+ def _union_output_filters(existing: list[str], additions: list[str]) -> list[str]:
359
+ """Existing chain + the filters it is missing, order-preserving.
360
+
361
+ ``OutputFilterChain.feed()`` applies filters left to right, so the
362
+ chain is a pipeline, not a set: reordering it changes the output
363
+ (e.g. ``strip_thinking`` before vs. after ``strip_tool_call_xml``
364
+ decides whether a tool call nested inside ``<think>`` survives).
365
+ Never sort this — the operator's existing order is authoritative and
366
+ the additions go on the end, which is the only placement that cannot
367
+ change the behavior of the filters already there.
368
+
369
+ Duplicates are dropped so a re-run of the probe cannot grow the
370
+ chain unboundedly.
371
+ """
372
+ chain = list(existing)
373
+ for name in additions:
374
+ if name not in chain:
375
+ chain.append(name)
376
+ return chain
377
+
378
+
343
379
  def _patch_providers_yaml_num_ctx(provider_name: str, desired_ctx: int = 32768) -> str:
344
380
  """v1.0-B: Emit a providers.yaml patch setting ``extra_body.options.num_ctx``.
345
381
 
@@ -470,6 +506,23 @@ _STREAMING_PROBE_MAX_TOKENS_THINKING = 1024
470
506
  # probes (same _is_reasoning_model gate).
471
507
  _TOOL_CALLS_PROBE_MAX_TOKENS_DEFAULT = 256
472
508
  _TOOL_CALLS_PROBE_MAX_TOKENS_THINKING = 1024
509
+ # How much assistant text the tool_calls probe feeds to
510
+ # ``repair_tool_calls_in_text``. This has to be sized against the token
511
+ # budgets above, not against a "preview" length: 1024 tokens is roughly
512
+ # 3-4k characters, and a thinking model routinely spends several hundred
513
+ # of them on a preamble before writing its JSON call. Scanning only the
514
+ # first 200 characters (the pre-fix behavior) made the probe conclude
515
+ # "nothing tool-shaped at all" for a model that had in fact emitted a
516
+ # perfectly repairable call — and the remediation for that verdict is a
517
+ # ``capabilities.tools: false`` patch, i.e. ``--apply`` would have
518
+ # permanently disabled tool calling for a provider that supports it.
519
+ # Bounded rather than unlimited so a pathological multi-megabyte
520
+ # response cannot turn the probe into a CPU sink; 16 KiB comfortably
521
+ # covers the largest response either budget can produce.
522
+ _TOOL_PROBE_SCAN_CHARS = 16384
523
+ # Length of the human-facing excerpt embedded in probe ``detail`` text.
524
+ # Display only — never the input to a verdict.
525
+ _TOOL_PROBE_DETAIL_PREVIEW_CHARS = 200
473
526
  # Default ``num_predict`` suggested in the emitted patch. -1 would be
474
527
  # optimal (uncapped) but "4096" communicates intent more clearly to
475
528
  # operators unfamiliar with Ollama's sentinel value, and covers Claude
@@ -1191,7 +1244,12 @@ async def _probe_tool_calls(
1191
1244
 
1192
1245
  native_tool_call = False
1193
1246
  text_json_tool_call = False
1194
- content_sample = ""
1247
+ # ``scan_text`` is what the verdict is computed from — the assistant
1248
+ # text up to _TOOL_PROBE_SCAN_CHARS. ``content_preview`` is a short
1249
+ # excerpt used only inside human-readable ``detail`` strings. Keeping
1250
+ # these two separate is the whole point: they used to be one 200-char
1251
+ # string, so the preview length silently became the detection window.
1252
+ scan_text = ""
1195
1253
  if provider.kind == "anthropic":
1196
1254
  blocks = parsed.get("content")
1197
1255
  if isinstance(blocks, list):
@@ -1199,11 +1257,11 @@ async def _probe_tool_calls(
1199
1257
  if isinstance(block, dict) and block.get("type") == "tool_use":
1200
1258
  native_tool_call = True
1201
1259
  break
1202
- content_sample = " ".join(
1260
+ scan_text = " ".join(
1203
1261
  str(b.get("text", ""))
1204
1262
  for b in blocks
1205
1263
  if isinstance(b, dict) and b.get("type") == "text"
1206
- )[:200]
1264
+ )[:_TOOL_PROBE_SCAN_CHARS]
1207
1265
  else:
1208
1266
  msg = _extract_openai_assistant_choice(parsed)
1209
1267
  if msg is not None:
@@ -1211,10 +1269,13 @@ async def _probe_tool_calls(
1211
1269
  native_tool_call = True
1212
1270
  content = msg.get("content")
1213
1271
  if isinstance(content, str):
1214
- content_sample = content[:200]
1272
+ scan_text = content[:_TOOL_PROBE_SCAN_CHARS]
1273
+
1274
+ content_preview = scan_text[:_TOOL_PROBE_DETAIL_PREVIEW_CHARS]
1275
+ scanned_chars = len(scan_text)
1215
1276
 
1216
- if not native_tool_call and content_sample:
1217
- _, repaired = repair_tool_calls_in_text(content_sample, ["echo"])
1277
+ if not native_tool_call and scan_text:
1278
+ _, repaired = repair_tool_calls_in_text(scan_text, ["echo"])
1218
1279
  text_json_tool_call = bool(repaired)
1219
1280
 
1220
1281
  # Resolve the declared support:
@@ -1293,14 +1354,24 @@ async def _probe_tool_calls(
1293
1354
  "the model produced no tool-shaped output at all."
1294
1355
  )
1295
1356
  )
1357
+ # State the detection window explicitly. This verdict's
1358
+ # remediation permanently disables tool calling, so an operator
1359
+ # who disagrees with it needs to be able to tell "the model
1360
+ # really emitted nothing" from "the probe only looked at the
1361
+ # first N characters" without reading the source.
1362
+ scan_note = (
1363
+ f"Scanned {scanned_chars} chars of assistant text "
1364
+ f"(window {_TOOL_PROBE_SCAN_CHARS})"
1365
+ + (f"; begins: {content_preview!r}." if content_preview else "; text was empty.")
1366
+ )
1296
1367
  return ProbeResult(
1297
1368
  name="tool_calls",
1298
1369
  verdict=ProbeVerdict.NEEDS_TUNING,
1299
1370
  detail=(
1300
1371
  "declaration says tools=true but model produced neither "
1301
1372
  "native `tool_calls` nor repairable tool JSON. "
1302
- f"{budget_note} Common for quantized small models "
1303
- "(plan.md §9.4 symptom #2)."
1373
+ f"{budget_note} {scan_note} Common for quantized small "
1374
+ "models (plan.md §9.4 symptom #2)."
1304
1375
  ),
1305
1376
  target_file="providers.yaml",
1306
1377
  suggested_patch=_patch_providers_yaml_capability(provider.name, "tools", False),
@@ -1526,7 +1597,10 @@ async def _probe_reasoning_leak(
1526
1597
  content_text = content if isinstance(content, str) else ""
1527
1598
  has_think = "<think>" in content_text
1528
1599
  leaked_markers: list[str] = [m for m in DEFAULT_STOP_MARKERS if m in content_text]
1529
- configured_filters = set(provider.output_filters)
1600
+ # Order matters (the chain is a left-to-right pipeline), so keep the
1601
+ # operator's list as a list — a set here would lose the order we have
1602
+ # to reproduce in the patch.
1603
+ configured_filters: list[str] = list(provider.output_filters)
1530
1604
  needs_strip_thinking = has_think and "strip_thinking" not in configured_filters
1531
1605
  needs_strip_markers = bool(leaked_markers) and "strip_stop_markers" not in configured_filters
1532
1606
 
@@ -1542,6 +1616,11 @@ async def _probe_reasoning_leak(
1542
1616
  if needs_strip_markers:
1543
1617
  recommended.append("strip_stop_markers")
1544
1618
 
1619
+ # The patch must carry the WHOLE chain: --apply replaces the
1620
+ # list wholesale, so emitting only ``recommended`` would drop
1621
+ # every filter the operator already configured.
1622
+ merged_chain = _union_output_filters(configured_filters, recommended)
1623
+
1545
1624
  found_desc: list[str] = []
1546
1625
  if has_think:
1547
1626
  found_desc.append("<think>...</think>")
@@ -1555,11 +1634,11 @@ async def _probe_reasoning_leak(
1555
1634
  "content-embedded leak detected ("
1556
1635
  + " + ".join(found_desc)
1557
1636
  + "). v1.0-A `output_filters` would scrub this; current "
1558
- f"provider chain = {sorted(configured_filters)}. Recommended: "
1559
- f"add {recommended}."
1637
+ f"provider chain = {configured_filters}. Recommended: "
1638
+ f"add {recommended} → resulting chain {merged_chain}."
1560
1639
  ),
1561
1640
  target_file="providers.yaml",
1562
- suggested_patch=_patch_providers_yaml_output_filters(provider.name, recommended),
1641
+ suggested_patch=_patch_providers_yaml_output_filters(provider.name, merged_chain),
1563
1642
  )
1564
1643
 
1565
1644
  passthrough_on = (