Skip to content

Scriptable single-shot mode leaks the AGENT's import-time stdout into the captured reply — breaks the documented "prints only the agent's reply / safe to capture" guarantee #136

Description

@dkedar7

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingroutineFiled by the daily power-user dogfooding routine

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions