Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ step-by-step checklist.
- `factory.py` - Provider instantiation

- **gates/**: Human-in-the-loop support
- `human.py` - Rich terminal UI for human gate interactions
- `human.py` - Rich terminal UI for human gate interactions. `read_multiline_lines(console, sentinel)` is the shared blocking multi-line stdin reader behind both the human gate's `.` sentinel (`MULTILINE_SENTINEL`) and the dialog gate's `/send` (`DIALOG_SUBMIT_SENTINEL`); it returns `(text, hit_eof)` because an EOF that yielded no text is a deliberate dismissal while the sentinel with no text is an empty submission. `sentinel` is keyword-only and required, since the two gates use different ones
- `dialog.py` - Dialog-mode gate. On a tty the main turn reads through `read_multiline_lines`, so a pasted block is one turn rather than one turn per line; off a tty it falls back to single-line `Prompt.ask`, where the sentinel has no effect — the `_reads_multiline_turn()` predicate states that rule once, and `_display_dialog_start` gates its sentinel hint on it so the banner never advertises a keystroke the reader ignores. Both submission guards compare against stripped text: the reader drops trailing newlines but keeps a whitespace-only line, so an exact `== ""` check would dispatch whitespace as a turn and turn Ctrl-D into a submission

- **interrupt/**: Interactive workflow interruption (Esc/Ctrl+G to pause)
- `listener.py` - Keyboard listener daemon thread for Esc/Ctrl+G detection
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`reason: "estimate_unavailable"`) rather than vanishing into stderr. See
[Workflow Syntax → Context Compaction](docs/workflow-syntax.md#context-compaction).

- **A multi-line reply to a terminal dialog is now one turn** (#509) —
dialog mode was the only free-text human-input surface that could not accept
a multi-line answer (`QuestionDef.multiline` defaults to `True` and
`GateOption.multiline` opts in, both served by one reader in `gates/human.py`).
The dialog gate read a reply with single-line `Prompt.ask`, so pasting a block
of text into an interactive terminal dispatched *each line* as its own turn: a
three-line paste became three separate questions to the model, each answered
against a fragment, and the paste's trailing newline added a fourth turn with
empty content. Terminal turns now read through the multi-line reader already
behind the human gate's `.` sentinel, submitted with `/send` on its own line,
so internal newlines survive and a paste is a single message. An empty or
whitespace-only submission is no longer dispatched as a turn. A dismiss
keyword is recognised only once a turn is submitted, so on a tty `done` now
needs `/send` after it, and both the opening banner and the failure-recovery
notice say so rather than naming a keystroke that does nothing there.

Ctrl-D at the start of a line (Ctrl-Z then Enter on Windows) also submits the
lines entered so far, or dismisses the dialog when there are none. Because a
terminal's EOF does not persist, abandoning a part-written reply that way now
sends what was already entered and a second Ctrl-D is needed to leave, where
one used to exit; on an empty prompt it still exits in one keystroke.

The dialog uses `/send` where the human gate keeps `.`, since a lone `.` is
likelier to be prose in a conversational reply. Off a tty — a pipe or CI —
replies are still read one line at a time and `/send` has no effect; the one
change on that path is that a blank line is now skipped instead of dispatched
as an empty turn. The web dashboard is unaffected: it takes a separate path
that already delivered each message whole.

### Changed

- **A `working_dir` or `settings_dir` template that renders empty is now an
Expand Down
1 change: 1 addition & 0 deletions docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,7 @@ After the conversation, the agent re-executes with the dialog transcript as addi

**Behavior notes:**
- Dialog is supported on regular `agent` type only (not `human_gate`, `questions`, `script`, `workflow`, or `wait`)
- In an interactive terminal, a reply may span multiple lines — paste or type freely and submit the turn with `/send` on its own line. A dismiss keyword ends the dialog the same way, so `done` needs `/send` after it there; off a tty every line is already a turn, so it does not. Ctrl-D at the start of a line (Ctrl-Z then Enter on Windows) also submits whatever lines have been entered so far, or dismisses the dialog when none have — so abandoning a part-written reply that way sends the lines already entered; press it on an empty prompt to leave without sending. An empty or whitespace-only submission is skipped rather than sent. Off a tty (a pipe or CI) replies are read one line at a time and `/send` does not apply, though a blank line is skipped rather than sent as an empty turn. The web dashboard is unaffected — its chat box takes a separate path that has always delivered each message whole, multi-line included
- In web dashboard mode, the dialog temporarily replaces the graph area with a chat interface
- When `--skip-gates` is set (e.g., CI/automation), dialogs are automatically skipped
- The evaluator prompt should describe *when* to trigger dialog, not *what* to ask — the evaluator generates the opening question from the agent's output context
Expand Down
99 changes: 93 additions & 6 deletions src/conductor/gates/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import asyncio
import json
import logging
import sys
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
Expand All @@ -22,6 +23,11 @@

from conductor.console import MarkupFreeConsole, make_console, styled
from conductor.executor.linkify import linkify_markdown
from conductor.gates.human import (
DIALOG_SUBMIT_SENTINEL,
read_multiline_lines,
read_on_daemon_thread,
)

if TYPE_CHECKING:
from pathlib import Path
Expand Down Expand Up @@ -81,6 +87,38 @@
_READY_MARKER = "[READY_TO_CONTINUE]"


def _reads_multiline_turn() -> bool:
"""Whether a conversational dialog turn is read multi-line.

Off a tty the turn falls back to a single-line ``Prompt.ask``, where the
submit sentinel has no effect. Both the reader in
``DialogHandler._get_user_input`` and the opening banner's sentinel hint
ask this, so the banner can never advertise a keystroke the reader ignores.
"""
return sys.stdin.isatty()


def _dismiss_instruction() -> Text:
"""How to actually leave the dialog, phrased for the active reader.

A dismiss keyword is only recognised once the turn is *submitted*, so on a
tty it needs the sentinel after it. Saying "type done" there describes a
keystroke that does nothing until the reply is sent -- the exit instruction
has to move with :func:`_reads_multiline_turn` or it contradicts the very
banner that advertises the sentinel.

Returns:
A pre-styled ``Text``, so a caller splices it through ``styled`` and
keeps its own template a literal (#406).
"""
if _reads_multiline_turn():
return styled(
"send [bold]done[/bold] or [bold]/done[/bold] with [bold]{}[/bold]",
DIALOG_SUBMIT_SENTINEL,
)
return Text.from_markup("say [bold]done[/bold] or [bold]/done[/bold]")


def _extract_ready_marker(response: str) -> tuple[bool, str]:
"""Return ``(proposed, cleaned)`` for an agent response.

Expand Down Expand Up @@ -273,6 +311,12 @@ async def handle_dialog(
result.user_dismissed = True
break

if not user_input.strip():
# Empty submission -- not a turn, and not dismissal either. On
# a tty this is a bare sentinel line; off a tty it is a blank
# line from the pipe, which ``Prompt.ask`` returns as "".
continue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

This line has no test coverage at all — --cov-report=term-missing flags it as missing, and deleting the whole if user_input == "": continue block leaves 138/138 tests passing across tests/test_gates, tests/test_engine/test_dialog_integration.py, tests/test_cli/test_markup_injection.py, and tests/test_engine/test_resume.py.

So nothing actually pins the behavior the CHANGELOG advertises. Without this guard, a bare /send with nothing typed above it appends an empty DialogMessage, emits a dialog_message event, and sends an empty turn to the provider — a real API call with no content. Neither half of what matters is verified: that the empty submission isn't dispatched, and that the loop keeps going so a real turn afterward still works.

@pytest.mark.asyncio
async def test_bare_sentinel_is_skipped_and_the_loop_continues(self) -> None:
    """An empty submission is neither a turn nor dismissal; the next turn still runs."""
    ...
    with (
        patch.object(handler, "_ask_engagement", new_callable=AsyncMock, return_value="engage"),
        patch("conductor.gates.dialog.sys.stdin.isatty", return_value=True),
        patch("builtins.input", side_effect=["/send", "real turn", "/send", "done", "/send"]),
    ):
        result = await handler.handle_dialog(...)
    user_msgs = [m.content for m in result.messages if m.role == "user"]
    assert user_msgs == ["real turn", "done"]        # no "" turn recorded
    provider.execute_dialog_turn.assert_awaited_once()  # and none dispatched
    assert result.user_dismissed is True                 # loop continued, then exited

This fails against the unguarded code (an empty message shows up first and the provider gets awaited twice). Worth pairing with a non-tty variant for the blank-piped-line case from the CHANGELOG finding above.


result.messages.append(DialogMessage(role="user", content=user_input))
self._emit_event(
"dialog_message",
Expand Down Expand Up @@ -309,9 +353,9 @@ async def handle_dialog(
exc_info=True,
)
self.console.print(
Text.from_markup(
"[dim red] (Agent response failed — you can continue "
"or type 'done')[/dim red]"
styled(
"[dim red] (Agent response failed — you can continue, or {})[/dim red]",
_dismiss_instruction(),
)
)
continue
Expand Down Expand Up @@ -600,15 +644,40 @@ def _display_dialog_start(
base_dir: Path | None = None,
) -> None:
"""Display the dialog opening with full agent context."""
# Advertised only where the sentinel applies -- see
# _reads_multiline_turn. Pre-rendered as a Text so the outer template
# has fixed arity: styled() splices a Text in with its own spans
# re-anchored, and raises IndexError on a template/argument mismatch
# that only the tty branch would reach. Off a tty the sentence keeps
# its original plural, since each line really is a separate response
# there.
instruction = (
styled(
"Type your response below. It can span multiple lines; send it"
" with [bold]{}[/bold] on its own line. A dismiss keyword ends"
" the dialog the same way: send [bold]done[/bold] or"
" [bold]/done[/bold] with [bold]{}[/bold].",
DIALOG_SUBMIT_SENTINEL,
DIALOG_SUBMIT_SENTINEL,
)
if _reads_multiline_turn()
# Byte-identical to upstream's sentence: off a tty every line is a
# turn already, so a dismiss keyword needs nothing after it.
else Text.from_markup(
"Type your responses below. Say [bold]done[/bold] or "
"[bold]/done[/bold] when finished."
)
)

self.console.print()
self.console.print(
Panel(
styled(
"[bold]Agent '{}'[/bold] would like to discuss "
"its output with you.\n"
"[dim]Type your responses below. Say [bold]done[/bold] or "
"[bold]/done[/bold] when finished.[/dim]",
"[dim]{}[/dim]",
agent.name,
instruction,
),
title=Text.from_markup("[bold magenta]Dialog Mode[/bold magenta]"),
border_style="magenta",
Expand Down Expand Up @@ -732,8 +801,26 @@ async def _get_user_input(
passing an interpolated f-string here.

Returns:
User input text, or None on EOF/error.
User input text, or None on EOF/error, which the caller treats as
dismissal. The main turn (``prompt_text is None`` on a tty) reads
multi-line, so an EOF that *terminates a paste* returns the
accumulated content rather than dismissing; an EOF with nothing
but whitespace accumulated is a deliberate Ctrl-D and still
returns None.
"""
if prompt_text is None and _reads_multiline_turn():
self.console.print(styled("[bold magenta]You[/bold magenta]"))
try:
text, hit_eof = await read_on_daemon_thread(
lambda: read_multiline_lines(self.console, sentinel=DIALOG_SUBMIT_SENTINEL)
)
except (EOFError, KeyboardInterrupt):
return None
if hit_eof and not text.strip():
# Ctrl-D at an empty prompt: the user is leaving, not pasting.
return None
return text

prompt = styled("[bold magenta]You[/bold magenta]") if prompt_text is None else prompt_text
try:

Expand Down
83 changes: 62 additions & 21 deletions src/conductor/gates/human.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,68 @@
MULTILINE_SENTINEL = "."
"""Line that terminates a multi-line answer when typed on its own."""

DIALOG_SUBMIT_SENTINEL = "/send"
"""Line that submits a multi-line dialog turn.

Distinct from :data:`MULTILINE_SENTINEL` because a lone ``.`` is likelier to
be an ordinary line of prose in a conversational reply than in a gate answer.
"""


def _eof_key_hint() -> str:
"""Return the platform-appropriate EOF keystroke for display."""
return "Ctrl-Z then Enter" if sys.platform == "win32" else "Ctrl-D"


def read_multiline_lines(console: MarkupFreeConsole, *, sentinel: str) -> tuple[str, bool]:
"""Read a multi-line answer from stdin (blocking; call on a thread).

Terminates on a line whose stripped text equals ``sentinel`` or on EOF.
Internal newlines are preserved; trailing empty lines are dropped, but a
trailing line of whitespace is kept verbatim -- a real strip would eat the
meaningful indentation of a pasted code block.

Args:
console: Console to print the input hint to.
sentinel: Line that, typed alone, submits the accumulated text.
Keyword-only and required: the two gates use different sentinels,
so a caller states which one it means.

Returns:
``(text, hit_eof)`` -- the collected text and whether the read ended at
EOF rather than at the sentinel. Callers need the distinction because
an EOF that yielded no text is a deliberate dismissal (Ctrl-D at an
empty prompt), whereas the sentinel with no text is merely an empty
submission.
"""
console.print(
styled(
" [dim]Enter your answer. Finish with '{}' on its own line (or {}).[/dim]",
sentinel,
_eof_key_hint(),
)
)
lines: list[str] = []
hit_eof = False
while True:
try:
line = input()
except EOFError:
# The only end-of-input a real stdin produces here: an exhausted
# or non-tty stream raises EOFError, a closed one ValueError.
# StopIteration is deliberately *not* caught -- input() would only
# relay it from a contrived stdin replacement, and treating it as
# EOF would submit a truncated turn as if the user had pressed
# Ctrl-D. A test double that runs past what it supplied is a bug
# in the test, so it must surface rather than read as a dismissal.
hit_eof = True
break
if line.strip() == sentinel:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

Mutating if line.strip() == sentinel: to if line == sentinel: still leaves 138/138 tests passing — every existing test types the sentinel byte-exactly ("." at test_human.py:766, "/send" at :774 and test_dialog.py:749).

A trailing space after /send is a normal keystroke, and terminals and paste buffers add them routinely. Without .strip() here, that line would get silently swallowed into the message body and the user would sit at a prompt that never submits. .strip() predates this PR, but this PR is what makes a five-character typed sentinel the primary way to send a dialog turn, so it's carrying a lot more weight now than before.

Worth flagging the flip side while you're adding coverage: .strip() also means a /send line inside a pasted block (a chat log, a shell transcript) truncates the paste with no warning, and the rest gets eaten as the next turn. /send is a much safer choice than the human gate's ., so this isn't a design objection — but a one-line echo after submission ((sent 3 lines)) would let the user catch it immediately.

@pytest.mark.parametrize("typed", ["/send", " /send", "/send ", "\t/send  "])
def test_sentinel_tolerates_surrounding_whitespace(self, typed: str) -> None:
    with patch("builtins.input", side_effect=["body", typed, "unreachable"]):
        text, hit_eof = read_multiline_lines(MagicMock(), sentinel="/send")
    assert (text, hit_eof) == ("body", False)

The "unreachable" entry also proves the reader stopped at the sentinel instead of just running out of mock values.

break
lines.append(line)
return "\n".join(lines).rstrip("\n"), hit_eof


async def read_on_daemon_thread[T](fn: Callable[[], T]) -> T:
"""Run a blocking stdin read on a daemon thread and await its result.

Expand Down Expand Up @@ -477,30 +533,15 @@ def _ask_value() -> str:
def _read_multiline(self) -> str:
"""Read a multi-line answer from stdin (blocking; call in a thread).

Terminates on a line containing only ``.`` or on EOF. The sentinel is
listed first in the hint because Ctrl-D/Ctrl-Z differs by platform.
Delegates to the shared :func:`read_multiline_lines` with this gate's
historical ``.`` sentinel.

Returns:
The collected text with trailing blank lines stripped. Internal
newlines are preserved.
The collected text, with trailing empty lines dropped and internal
newlines preserved.
"""
self.console.print(
styled(
" [dim]Enter your answer. Finish with '{}' on its own line (or {}).[/dim]",
MULTILINE_SENTINEL,
_eof_key_hint(),
)
)
lines: list[str] = []
while True:
try:
line = input()
except EOFError:
break
if line.strip() == MULTILINE_SENTINEL:
break
lines.append(line)
return "\n".join(lines).rstrip("\n")
text, _ = read_multiline_lines(self.console, sentinel=MULTILINE_SENTINEL)
return text


@dataclass
Expand Down
Loading
Loading