Skip to content

Commit 386bc92

Browse files
authored
Merge pull request #938 from Pipelex/release/v0.30.0
Release v0.30.0
2 parents c824d73 + 8b99246 commit 386bc92

24 files changed

Lines changed: 1475 additions & 89 deletions

.badges/tests.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"schemaVersion": 1,
33
"label": "tests",
4-
"message": "6095",
4+
"message": "6111",
55
"color": "blue",
66
"cacheSeconds": 300
77
}

.pipelex/pipelex.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,17 @@ default_directory_base_name = "pipeline"
161161
# Default logging level: "DEBUG", "INFO", "WARNING", "ERROR"
162162
default_log_level = "INFO"
163163
# Log output target: "stdout" or "stderr"
164-
console_log_target = "stdout"
164+
# console_log_target controls the RichHandler used by the logging system (log.debug/info/...).
165+
# Logs are diagnostics → stderr.
166+
console_log_target = "stderr"
167+
# console_print_target controls the Console returned by get_console(), used for banners,
168+
# deck notices, and the main `pipelex` CLI's data tables (`show backends`, `show models`,
169+
# `which`, `doctor`). Tables are data the user pipes to a file → stdout.
170+
# Note: the agent CLI (`pipelex-agent`) emits its actual JSON/markdown results via bare
171+
# builtin `print(...)` to sys.stdout, bypassing Rich entirely — so this knob does NOT
172+
# affect the agent CLI data channel. The agent CLI factory additionally forces both
173+
# targets to stderr internally to keep its stdout strictly reserved for the success
174+
# envelope.
165175
console_print_target = "stdout"
166176

167177
[pipelex.log_config.package_log_levels]

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [v0.30.0] - 2026-05-25
4+
5+
### Fixed
6+
7+
- **`console_log_target` package default is now `stderr` (was `stdout`).** Logs now stay off the data channel by default, matching the intent of PR #452 ("default to stderr for outputs happening before initialization"). Downstream tooling that parses `pipelex` / `pipelex-agent` stdout as JSON (e.g. `mthds-js`'s `PipelexRunner`) is no longer at risk of stdout pollution from package-level logs — the bug was latent for stock installs because the agent-CLI JSON paths happen not to log at INFO+, but surfaced for anyone who raised `package_log_levels.pipelex` to DEBUG or added a setup-time log on the command path. The same flip is applied to the kit template (`pipelex/kit/configs/pipelex.toml`) that `pipelex init` copies to `~/.pipelex/`. **Note:** `console_print_target` is intentionally left at `stdout` — the main `pipelex` CLI emits human-facing tables (`show backends`, `show models`, `which`, `doctor`) via that channel, and downstream piping (`pipelex show backends > out.txt`) must keep working.
8+
9+
- **`pipelex-agent` now pins both console targets to `stderr` regardless of user config.** `make_pipelex_for_agent_cli` injects `config_overrides` into `Pipelex.make()` that force `console_log_target = "stderr"` and `console_print_target = "stderr"` from the very first log/print fired during init, so a user override of either knob in `~/.pipelex/pipelex.toml` can no longer leak diagnostics onto the agent CLI's JSON data channel. Defense-in-depth post-init calls to `log.redirect_to_stderr()` and `get_pipelex_hub().set_console_print_target(STDERR)` remain. A new adversarial E2E test (`tests/e2e/agent_cli/test_stdout_is_clean_json.py::test_models_json_stdout_resists_user_targets_override_to_stdout`) pins the contract by overriding both targets to `stdout` and `package_log_levels.pipelex` to `DEBUG` and asserting `json.loads(stdout)` still parses.
10+
311
## [v0.29.1] - 2026-05-21
412

513
### Fixed

pipelex/cli/agent_cli/commands/agent_cli_factory.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
"""Factory function for agent CLI commands -- JSON-only error output."""
22

33
import warnings
4+
from collections.abc import Mapping
45
from pathlib import Path
6+
from types import MappingProxyType
7+
from typing import Any
58

69
import typer
710

811
from pipelex.cli.agent_cli.commands.agent_output import agent_error, record_setup_warning
912
from pipelex.cogt.exceptions import GatewayUnknownModelError, ModelDeckPresetValidatonError
13+
from pipelex.hub import PipelexHub
1014
from pipelex.pipelex import Pipelex
15+
from pipelex.system.console_target import ConsoleTarget
1116
from pipelex.system.pipelex_service.exceptions import (
1217
GatewayApiKeyMissingError,
1318
GatewayDoNotTrackConflictError,
@@ -23,6 +28,77 @@
2328
from pipelex.tools.log.log_levels import LogLevel
2429
from pipelex.tools.misc.pretty import PrettyPrinter, PrettyPrintMode
2530

31+
# Canonical leaf dict for "for any agent-CLI invocation, both Rich-managed channels land
32+
# on stderr." Composed by two distinct call sites:
33+
# - ``_AGENT_CLI_STDERR_CONSOLE_OVERRIDES`` below wraps it in the full-config-tree shape
34+
# required by ``Pipelex.make(config_overrides=...)`` for the full-init path.
35+
# - ``pipelex.cli.agent_cli.commands.doctor_cmd`` passes it flat to
36+
# ``setup_doctor_runtime(log_config_overrides=...)`` for the doctor-only path that
37+
# does not go through ``Pipelex.make``.
38+
#
39+
# These two knobs only control Rich-managed diagnostic channels — NOT the agent CLI's
40+
# data channel:
41+
# - ``console_log_target`` -> the ``RichHandler`` for Python's logging system
42+
# (``log.debug/info/warning/error``).
43+
# - ``console_print_target`` -> the ``Console`` returned by ``get_console()`` and used
44+
# for banners, deck notices, and the main ``pipelex`` CLI's
45+
# ``show backends`` / ``show models`` tables.
46+
#
47+
# The agent CLI's actual results (the JSON / markdown success envelope) are written by
48+
# ``agent_success`` / ``agent_success_formatted`` in ``agent_output.py`` via the bare
49+
# builtin ``print(...)`` straight to ``sys.stdout``. They do NOT go through Rich, so they
50+
# are unaffected by these overrides. Error envelopes go via ``print(..., file=sys.stderr)``
51+
# — also bypassing Rich.
52+
#
53+
# Pinning both targets to ``stderr`` therefore ensures that EVERYTHING ELSE Pipelex might
54+
# emit (setup-time ``log.debug`` from ``telemetry_factory.py``, a banner from
55+
# ``deck_notice.py``, any future ``get_console().print(...)`` on the agent CLI setup path)
56+
# lands on stderr regardless of what the user's ``~/.pipelex/pipelex.toml`` says — so the
57+
# JSON data channel on stdout stays clean for downstream consumers like ``mthds-js``'s
58+
# ``PipelexRunner`` that do ``JSON.parse(stdout)``.
59+
#
60+
# Wrapped in ``MappingProxyType`` so a stray ``AGENT_CLI_STDERR_LOG_FIELDS[...] = ...`` in
61+
# a future contributor's hot-fix raises immediately instead of silently mutating the
62+
# shared canonical instance (which both consumers below alias).
63+
AGENT_CLI_STDERR_LOG_FIELDS: Mapping[str, Any] = MappingProxyType(
64+
{
65+
"console_log_target": ConsoleTarget.STDERR,
66+
"console_print_target": ConsoleTarget.STDERR,
67+
}
68+
)
69+
70+
# Direct alias: deep_update recurses into any ``Mapping`` and converts frozen leaves to
71+
# plain dicts at merge time, so referencing the canonical ``MappingProxyType`` here is
72+
# safe AND keeps mutation attempts on either reference loud (TypeError on the frozen
73+
# proxy) instead of silently diverging.
74+
_AGENT_CLI_STDERR_CONSOLE_OVERRIDES: dict[str, Any] = {
75+
"pipelex": {"log_config": AGENT_CLI_STDERR_LOG_FIELDS},
76+
}
77+
78+
79+
def apply_agent_cli_output_discipline(log_level: LogLevel = LogLevel.WARNING) -> None:
80+
"""Pin pipelex log level, pretty-print silence, and hub console target to stderr.
81+
82+
Called from two paths:
83+
- ``make_pipelex_for_agent_cli`` (full-init): defense-in-depth. ``Pipelex.__init__``
84+
already pinned the hub console via ``set_console_print_target`` from the loaded
85+
log_config (whose ``console_print_target`` was overridden to STDERR by
86+
``_AGENT_CLI_STDERR_CONSOLE_OVERRIDES``).
87+
- ``agent_doctor_cmd`` (doctor-only): also defense-in-depth, since
88+
``setup_doctor_runtime`` now mirrors ``Pipelex.__init__`` and applies
89+
``set_console_print_target`` itself from the overridden log_config.
90+
91+
Safe to call from the broken-config doctor path where ``setup_doctor_runtime`` was
92+
skipped: ``log.redirect_to_stderr`` no-ops when no rich_handler is registered, and
93+
the hub print-target call is gated on a hub being installed.
94+
"""
95+
log.set_level_for_package("pipelex", log_level)
96+
log.redirect_to_stderr()
97+
PrettyPrinter.mode = PrettyPrintMode.SILENT
98+
hub = PipelexHub.get_optional_instance()
99+
if hub is not None:
100+
hub.set_console_print_target(target=ConsoleTarget.STDERR)
101+
26102

27103
def make_pipelex_for_agent_cli(
28104
library_dirs: list[str] | list[Path] | None = None,
@@ -41,6 +117,20 @@ def make_pipelex_for_agent_cli(
41117
human-readable markdown to stdout and exits 0, so the calling agent
42118
can display setup guidance directly.
43119
120+
Stdout contract: every ``pipelex-agent`` invocation reserves stdout exclusively
121+
for the structured success envelope (JSON via ``--format json``, or markdown via
122+
``--format markdown``) emitted by ``agent_success`` / ``agent_success_formatted``.
123+
All other output channels — logs, ``get_console().print(...)`` output, Rich pretty
124+
prints, and ``agent_error`` envelopes — are pinned to stderr regardless of what the
125+
user's ``pipelex.toml`` says. The factory enforces this via three layers:
126+
127+
1. ``config_overrides`` injected into ``Pipelex.make()`` pins both
128+
``console_log_target`` and ``console_print_target`` to ``stderr`` from the
129+
very first log/print fired during init.
130+
2. ``PrettyPrinter.mode = SILENT`` neutralizes ``pretty_print(...)`` entirely.
131+
3. Post-init ``log.redirect_to_stderr()`` and
132+
``get_pipelex_hub().set_console_print_target(STDERR)`` defense-in-depth.
133+
44134
Args:
45135
library_dirs: Optional library directories to use for the Pipelex instance.
46136
log_level: Log verbosity level (default WARNING for silent agent output).
@@ -58,7 +148,11 @@ def make_pipelex_for_agent_cli(
58148
with warnings.catch_warnings(record=True) as caught:
59149
warnings.simplefilter("always", RemoteConfigStaleWarning)
60150
pipelex_instance = Pipelex.make(
61-
integration_mode=IntegrationMode.CLI, library_dirs=library_dirs, needs_inference=needs_inference, needs_model_specs=needs_model_specs
151+
integration_mode=IntegrationMode.CLI,
152+
library_dirs=library_dirs,
153+
needs_inference=needs_inference,
154+
needs_model_specs=needs_model_specs,
155+
config_overrides=_AGENT_CLI_STDERR_CONSOLE_OVERRIDES,
62156
)
63157
# Surface a structured ``RemoteConfigStale`` entry so JSON consumers can react to
64158
# stale-cache operation without parsing stderr.
@@ -117,7 +211,5 @@ def make_pipelex_for_agent_cli(
117211

118212
# Suppress Rich pretty-printing and INFO/DEV/DEBUG log noise so that agent
119213
# commands only emit structured JSON. Warnings and errors still reach stderr.
120-
PrettyPrinter.mode = PrettyPrintMode.SILENT
121-
log.set_level_for_package("pipelex", log_level)
122-
log.redirect_to_stderr()
214+
apply_agent_cli_output_discipline(log_level=log_level)
123215
return pipelex_instance

pipelex/cli/agent_cli/commands/doctor_cmd.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44

55
import typer
66

7+
from pipelex.base_exceptions import PipelexConfigError
8+
from pipelex.cli.agent_cli.commands.agent_cli_factory import AGENT_CLI_STDERR_LOG_FIELDS, apply_agent_cli_output_discipline
79
from pipelex.cli.agent_cli.commands.agent_output import CliOutputFormat, agent_error, agent_success, set_agent_cli_error_format
810
from pipelex.cli.commands.doctor_cmd import (
11+
BackendFileReport,
912
ConfigLocationInfo,
1013
check_backend_credentials,
1114
check_config_files,
1215
check_models,
1316
check_telemetry_config,
1417
gather_config_location,
18+
setup_doctor_runtime,
1519
)
1620
from pipelex.system.configuration.config_loader import config_manager
1721

@@ -67,7 +71,11 @@ def _format_doctor_markdown(result: dict[str, Any]) -> str:
6771

6872
# Models
6973
models_check = checks["models"]
70-
lines.append(f"\n## Models \u2014 {_status_icon(models_check['healthy'])}\n")
74+
models_skipped_flag = models_check.get("skipped", False)
75+
# Skipped state renders with the warn icon so consumers don't confuse "deferred until
76+
# config is fixed" with a genuine models failure.
77+
models_icon = "\u26a0\ufe0f" if models_skipped_flag else _status_icon(models_check["healthy"])
78+
lines.append(f"\n## Models \u2014 {models_icon}\n")
7179
lines.append(models_check["message"])
7280
for file_entry in models_check.get("backend_files", []):
7381
name = file_entry["backend_name"]
@@ -132,14 +140,59 @@ def agent_doctor_cmd(
132140
else:
133141
config_location = gather_config_location()
134142

143+
# Filesystem-only checks run BEFORE bootstrap: when no --global override is in
144+
# play, setup_doctor_runtime's load_config materializes ~/.pipelex/ from kit
145+
# templates as a side effect. Running it first would turn check_config_files into
146+
# a silent installer on a fresh machine. (The --global path skips materialization
147+
# — see load_config.)
135148
config_healthy, config_missing_count, config_message = check_config_files(config_dir=config_dir)
136149
telemetry_healthy, telemetry_message = check_telemetry_config(config_dir=config_dir)
137150
backends_healthy, backend_credential_reports, backends_message = check_backend_credentials(config_dir=config_dir)
138-
models_healthy, models_message, backend_file_reports = check_models(config_dir=config_dir)
151+
152+
# check_models requires the hub + log.configure produced by setup_doctor_runtime.
153+
# When the config is broken (either shape-check fails up front or full validation
154+
# raises PipelexConfigError inside the bootstrap), we skip running check_models and
155+
# mark models as skipped — keeping the partial check tuples gathered above so the
156+
# JSON envelope still reports telemetry/backends instead of degrading to a single
157+
# error payload.
158+
models_skipped: bool = False
159+
models_healthy: bool
160+
models_message: str
161+
backend_file_reports: dict[str, BackendFileReport]
162+
if config_healthy:
163+
try:
164+
setup_doctor_runtime(log_config_overrides=AGENT_CLI_STDERR_LOG_FIELDS, config_dir=config_dir)
165+
# Pin discipline BEFORE check_models. setup_doctor_runtime uses
166+
# log.configure_if_unset(), which no-ops when a prior process already configured
167+
# logging (embedded reuse, interleaved tests) — in that case AGENT_CLI_STDERR_LOG_FIELDS
168+
# never reaches the handler, and any log line check_models emits could land on stdout.
169+
# apply_agent_cli_output_discipline mutates the existing handler unconditionally via
170+
# log.redirect_to_stderr, closing that window before any check fires.
171+
apply_agent_cli_output_discipline()
172+
models_healthy, models_message, backend_file_reports = check_models(config_dir=config_dir)
173+
except PipelexConfigError as exc:
174+
# A config can pass check_config_files's shape check and still fail full validation
175+
# inside setup_doctor_runtime (e.g. a layered override file). Surface the translated
176+
# message via the same partial-report shape as the broken-config branch.
177+
models_skipped = True
178+
models_healthy = False
179+
models_message = f"skipped — {exc.message}"
180+
backend_file_reports = {}
181+
else:
182+
models_skipped = True
183+
models_healthy = False
184+
models_message = "skipped — fix configuration errors first"
185+
backend_file_reports = {}
139186
except Exception as exc: # noqa: BLE001
140-
# Agent CLI command boundary: agent_error() (NoReturn) converts any unexpected failure into the structured error payload.
187+
# Agent CLI command boundary: agent_error() (NoReturn) converts any genuinely
188+
# unexpected failure into the structured error payload. PipelexConfigError is
189+
# handled by the inner arm above and never reaches here.
141190
agent_error(f"Health check failed unexpectedly: {exc}", type(exc).__name__, cause=exc)
142191

192+
# Pin stdout discipline regardless of bootstrap path (broken-config branch never
193+
# installed a hub or configured log — the helper guards both internally).
194+
apply_agent_cli_output_discipline()
195+
143196
all_healthy = config_healthy and telemetry_healthy and backends_healthy and models_healthy
144197

145198
# Build backend credential details
@@ -215,6 +268,7 @@ def agent_doctor_cmd(
215268
},
216269
"models": {
217270
"healthy": models_healthy,
271+
"skipped": models_skipped,
218272
"message": models_message,
219273
"backend_files": backend_files_list,
220274
},

0 commit comments

Comments
 (0)