Summary
When the CLI runs in its default interactive mode and the agent raises a LangGraph interrupt(...) (the headline human-in-the-loop approval feature), but stdin is not a TTY — i.e. the exact scriptable / piped / CI / cron context the README promotes — the CLI:
- Prints the interactive approval menu to
stdout (including the \033[?25l cursor-hide code and ❯ glyphs), violating the documented "only the agent's reply" quiet contract for piped single-shot runs, then
- Crashes with a cryptic
Error: (25, 'Inappropriate ioctl for device') on stderr and exits 1.
--no-interactive handles the identical scenario cleanly, and on a real terminal the menu works perfectly — so the defect is specifically a missing non-TTY guard in the interactive approval path. The CLI already detects non-TTY elsewhere (sys.stdout.isatty() at cli.py:1634, used to auto-enable quiet output); that same detection is simply not applied to the approval prompt.
Reproduces on a clean pip install langstage-cli (0.6.18, langstage-core 1.0.19).
Root cause
get_key() → Unix branch (langstage_cli/cli.py:791-793):
# Unix implementation using termios/tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd) # <-- raises termios.error (25) when stdin is not a TTY
There is no sys.stdin.isatty() guard before termios.tcgetattr(). When stdin isn't a terminal, tcgetattr raises termios.error: (25, 'Inappropriate ioctl for device'), which surfaces as the cryptic top-level Error: (25, ...).
Compounding it, select_option() (cli.py:815+) prints the prompt/menu before ever calling get_key():
print("\033[?25l", end="") # cursor hide -> stdout
print(f"\n{BOLD}{prompt}{RESET}") # "How would you like to proceed?" -> stdout
for i, opt in enumerate(options): ... # menu options -> stdout
while True:
key = get_key() # <-- only now does it crash
So even before the crash, the menu has already polluted stdout — which for a piped single-shot run is contractually supposed to contain only the agent's reply.
Reproduction (fully deterministic, no PTY needed)
hitl_agent.py:
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt
from langchain_core.messages import AIMessage
def ask(state):
decision = interrupt({"action": "delete_file", "path": "/etc/hosts", "question": "Approve deletion?"})
return {"messages": [AIMessage(content=f"Decision was: {decision}")]}
g = StateGraph(MessagesState)
g.add_node("ask", ask)
g.add_edge(START, "ask")
g.add_edge("ask", END)
graph = g.compile(checkpointer=MemorySaver())
# Non-interactive stdin — CI / cron / a script capturing output:
langstage-cli -a hitl_agent.py:graph "please act" </dev/null
Expected vs actual
Expected — since the CLI knows stdin/stdout isn't a TTY, either fall back to a documented behavior (e.g. behave like --no-interactive, or auto-reject) or exit with a clear, actionable message, e.g.:
Error: approval required but stdin is not a terminal.
Re-run with --no-interactive to auto-approve pending actions.
…and write nothing to stdout except the reply.
Actual (</dev/null, exit 1):
[?25l
How would you like to proceed?
❯ Approve all actions
Reject all actions
Provide custom decision (JSON)
Exit
[?25h
Error: (25, 'Inappropriate ioctl for device') # <- stderr
Stream separation confirms the contract violation — stdout (should be reply-only) gets the menu:
$ langstage-cli -a hitl_agent.py:graph "please act" </dev/null >out.txt 2>err.txt ; echo $?
1
$ cat -v out.txt
^[[?25l
How would you like to proceed?
M-bM-^]M-/ Approve all actions
Reject all actions
Provide custom decision (JSON)
Exit
^[[?25h
$ cat err.txt
Error: (25, 'Inappropriate ioctl for device')
Proof it's specifically the non-TTY path (not the feature)
- Real terminal (PTY): the menu renders, arrow-keys/Enter work, and the reply prints —
⏺ Decision was: {'decisions': [{'type': 'approve'}]}. ✅
--no-interactive, same non-TTY context: clean — diagnostics to stderr, reply to stdout, exit 0:
$ langstage-cli -a hitl_agent.py:graph --no-interactive "please act" </dev/null ; echo $?
Auto-approving 1 pending action(s) (--no-interactive) # stderr
Decision was: {'decisions': [{'type': 'approve'}]} # stdout
0
Why it matters
The README actively promotes scriptable single-shot capture (answer=$(langstage-cli -a my_agent.py:graph "do X")) and CI gating, and HITL approval is a headline feature. A real adopter who combines the two — an interrupting agent run non-interactively — hits a raw termios errno with no hint that --no-interactive exists, plus corrupted stdout. The fix is a one-line isatty guard reusing detection the CLI already performs.
Suggested fix
In get_key() / select_option() / handle_interrupt_input(), guard on sys.stdin.isatty() (and/or sys.stdout.isatty()) up front. When not a TTY, skip the arrow-key menu and either apply the --no-interactive behavior or raise a clear Error: telling the user to pass --no-interactive. Do not emit menu output to a non-TTY stdout.
Environment
- langstage-cli 0.6.18 (PyPI), langstage-core 1.0.19, langgraph 1.2.9, langchain-core 1.4.9, click 8.4.2
- Python 3.11.15, Linux
- Repro needs no PTY:
</dev/null (or any pipe/redirect on stdin) triggers it deterministically.
Severity
Major — a documented, promoted scriptable/CI path crashes with a cryptic error (and corrupts stdout) whenever the agent uses the headline HITL feature. Distinct from #84 (spinner line-clear erasing replies); this is the interrupt-approval non-TTY path.
Summary
When the CLI runs in its default interactive mode and the agent raises a LangGraph
interrupt(...)(the headline human-in-the-loop approval feature), but stdin is not a TTY — i.e. the exact scriptable / piped / CI / cron context the README promotes — the CLI:stdout(including the\033[?25lcursor-hide code and❯glyphs), violating the documented "only the agent's reply" quiet contract for piped single-shot runs, thenError: (25, 'Inappropriate ioctl for device')on stderr and exits1.--no-interactivehandles the identical scenario cleanly, and on a real terminal the menu works perfectly — so the defect is specifically a missing non-TTY guard in the interactive approval path. The CLI already detects non-TTY elsewhere (sys.stdout.isatty()atcli.py:1634, used to auto-enable quiet output); that same detection is simply not applied to the approval prompt.Reproduces on a clean
pip install langstage-cli(0.6.18, langstage-core 1.0.19).Root cause
get_key()→ Unix branch (langstage_cli/cli.py:791-793):There is no
sys.stdin.isatty()guard beforetermios.tcgetattr(). When stdin isn't a terminal,tcgetattrraisestermios.error: (25, 'Inappropriate ioctl for device'), which surfaces as the cryptic top-levelError: (25, ...).Compounding it,
select_option()(cli.py:815+) prints the prompt/menu before ever callingget_key():So even before the crash, the menu has already polluted
stdout— which for a piped single-shot run is contractually supposed to contain only the agent's reply.Reproduction (fully deterministic, no PTY needed)
hitl_agent.py:Expected vs actual
Expected — since the CLI knows stdin/stdout isn't a TTY, either fall back to a documented behavior (e.g. behave like
--no-interactive, or auto-reject) or exit with a clear, actionable message, e.g.:…and write nothing to stdout except the reply.
Actual (
</dev/null, exit 1):Stream separation confirms the contract violation —
stdout(should be reply-only) gets the menu:Proof it's specifically the non-TTY path (not the feature)
⏺ Decision was: {'decisions': [{'type': 'approve'}]}. ✅--no-interactive, same non-TTY context: clean — diagnostics to stderr, reply to stdout, exit 0:Why it matters
The README actively promotes scriptable single-shot capture (
answer=$(langstage-cli -a my_agent.py:graph "do X")) and CI gating, and HITL approval is a headline feature. A real adopter who combines the two — an interrupting agent run non-interactively — hits a rawtermioserrno with no hint that--no-interactiveexists, plus corrupted stdout. The fix is a one-lineisattyguard reusing detection the CLI already performs.Suggested fix
In
get_key()/select_option()/handle_interrupt_input(), guard onsys.stdin.isatty()(and/orsys.stdout.isatty()) up front. When not a TTY, skip the arrow-key menu and either apply the--no-interactivebehavior or raise a clearError:telling the user to pass--no-interactive. Do not emit menu output to a non-TTY stdout.Environment
</dev/null(or any pipe/redirect on stdin) triggers it deterministically.Severity
Major — a documented, promoted scriptable/CI path crashes with a cryptic error (and corrupts stdout) whenever the agent uses the headline HITL feature. Distinct from #84 (spinner line-clear erasing replies); this is the interrupt-approval non-TTY path.