Summary
In scriptable single-shot mode (piped stdout, or explicit -q/--quiet), anything the agent module (or one of its transitive dependencies) prints to stdout at import time is emitted on the CLI's stdout, before the reply. So answer=$(langstage-cli -a my_agent.py:graph "hi") captures the banner plus the reply, not just the reply.
This directly contradicts the README's scriptability contract ("Scriptable output"):
A single-shot run (a MESSAGE argument or -f/--file) prints only the agent's reply — no header, spinner, tool chatter, timing, or color … Errors and diagnostics go to stderr … so a run is safe to capture
The agent-load phase runs while stdout is still the real pipe, and nothing redirects it under _QUIET — even though the CLI already routes its own chrome to stderr in quiet mode. This is the agent-load sibling of the (now closed) CLI-chrome leaks #53 / #77 / #93, none of which guard the module-import phase.
Environment
langstage-cli 0.6.30 (from PyPI, clean venv), Python 3.11, Linux
langstage-core 1.0.34, langgraph 1.2.11
Minimal repro
cat > noisy_agent.py <<'EOF'
import sys
print("IMPORT-TIME-STDOUT-BANNER") # e.g. a library/model banner at import
print("WARN: something", file=sys.stderr) # stderr is fine
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langchain_core.messages import AIMessage
def respond(state):
return {"messages": [AIMessage(content="CLEAN-REPLY")]}
g = StateGraph(MessagesState); g.add_node("respond", respond)
g.add_edge(START, "respond"); g.add_edge("respond", END)
graph = g.compile()
EOF
# The README's own "safe to capture" idiom:
answer=$(langstage-cli -a noisy_agent.py:graph "hi" 2>/dev/null)
echo "CAPTURED >>>$answer<<<"
Expected vs actual
Expected (per README): captured stdout is exactly the reply.
CAPTURED >>>CLEAN-REPLY<<<
Actual: the agent's import-time stdout is captured too.
CAPTURED >>>IMPORT-TIME-STDOUT-BANNER
CLEAN-REPLY<<<
Evidence
Reproduces identically across every single-shot form; the demo agent (no custom import) stays clean; and the banner is provably on stdout, not stderr:
$ langstage-cli -a noisy_agent.py:graph "hi" 2>/dev/null | cat -A # piped (auto-scriptable)
IMPORT-TIME-STDOUT-BANNER$
CLEAN-REPLY$
$ langstage-cli -q -a noisy_agent.py:graph "hi" 2>/dev/null | cat -A # explicit -q
IMPORT-TIME-STDOUT-BANNER$
CLEAN-REPLY$
$ echo hi > p.txt; langstage-cli -a noisy_agent.py:graph -f p.txt 2>/dev/null | cat -A # -f form
IMPORT-TIME-STDOUT-BANNER$
CLEAN-REPLY$
$ langstage-cli --demo "hi" 2>/dev/null | cat -A # control: clean
(demo agent) You said: hi$
$ langstage-cli -a noisy_agent.py:graph "hi" 2>&1 1>/dev/null | cat -A # stderr only -> banner is NOT here
WARN: something$
Why this matters (not a contrived case)
Import-time stdout chatter is common in the exact dependency stacks LangGraph agents pull in: model/ML libraries and tokenizers print load banners or progress, some SDKs print first-run/telemetry notices, pygame-style "hello" lines, etc. A user following the README's answer=$(langstage-cli ...) example silently gets corrupted data — no error, exit 0 — which is the worst failure mode for a "safe to capture" contract, and defeats --verify/single-shot use in CI pipelines that parse the reply.
Mechanism / pointer
The single-shot path resolves and imports the agent via load_agent_spec(...) (langstage_cli/cli.py:650) with stdout still connected to the pipe. _QUIET is honored for the CLI's own chrome — _status() routes to stderr under _QUIET (cli.py:103‑105), and the "Loading agent" spinner is suppressed (cli.py:2534) — but the module-import itself is not wrapped in any stdout redirect. (stdout is only redirected to devnull much later, at interpreter shutdown, cli.py:2666‑2671.)
Suggested fix
In quiet/scriptable mode, wrap the agent-load/import phase (and ideally the whole turn's non-reply path) so agent-emitted stdout is redirected — either to stderr (consistent with "diagnostics go to stderr") or suppressed via contextlib.redirect_stdout — leaving only the streamed reply on stdout. This mirrors the treatment _status() already applies to the CLI's own output.
Severity
major — silently corrupts the documented "safe to capture" scriptable/CI contract for any agent whose dependency stack prints at import; no error surfaced.
Summary
In scriptable single-shot mode (piped stdout, or explicit
-q/--quiet), anything the agent module (or one of its transitive dependencies) prints tostdoutat import time is emitted on the CLI's stdout, before the reply. Soanswer=$(langstage-cli -a my_agent.py:graph "hi")captures the banner plus the reply, not just the reply.This directly contradicts the README's scriptability contract ("Scriptable output"):
The agent-load phase runs while stdout is still the real pipe, and nothing redirects it under
_QUIET— even though the CLI already routes its own chrome to stderr in quiet mode. This is the agent-load sibling of the (now closed) CLI-chrome leaks #53 / #77 / #93, none of which guard the module-import phase.Environment
langstage-cli0.6.30 (from PyPI, clean venv), Python 3.11, Linuxlangstage-core1.0.34,langgraph1.2.11Minimal repro
Expected vs actual
Expected (per README): captured stdout is exactly the reply.
Actual: the agent's import-time stdout is captured too.
Evidence
Reproduces identically across every single-shot form; the demo agent (no custom import) stays clean; and the banner is provably on stdout, not stderr:
Why this matters (not a contrived case)
Import-time
stdoutchatter is common in the exact dependency stacks LangGraph agents pull in: model/ML libraries and tokenizers print load banners or progress, some SDKs print first-run/telemetry notices,pygame-style "hello" lines, etc. A user following the README'sanswer=$(langstage-cli ...)example silently gets corrupted data — no error, exit 0 — which is the worst failure mode for a "safe to capture" contract, and defeats--verify/single-shot use in CI pipelines that parse the reply.Mechanism / pointer
The single-shot path resolves and imports the agent via
load_agent_spec(...)(langstage_cli/cli.py:650) with stdout still connected to the pipe._QUIETis honored for the CLI's own chrome —_status()routes to stderr under_QUIET(cli.py:103‑105), and the "Loading agent" spinner is suppressed (cli.py:2534) — but the module-import itself is not wrapped in any stdout redirect. (stdout is only redirected to devnull much later, at interpreter shutdown,cli.py:2666‑2671.)Suggested fix
In quiet/scriptable mode, wrap the agent-load/import phase (and ideally the whole turn's non-reply path) so agent-emitted
stdoutis redirected — either to stderr (consistent with "diagnostics go to stderr") or suppressed viacontextlib.redirect_stdout— leaving only the streamed reply on stdout. This mirrors the treatment_status()already applies to the CLI's own output.Severity
major — silently corrupts the documented "safe to capture" scriptable/CI contract for any agent whose dependency stack prints at import; no error surfaced.