Skip to content

Interactive HITL approval crashes with cryptic Error: (25, 'Inappropriate ioctl for device') on a non-TTY (piped / CI / cron) single-shot run — and leaks the menu to stdout #86

Description

@dkedar7

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:

  1. 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
  2. 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.

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