Skip to content

MarkupError kills the run when agent text contains a bracketed closing-tag form (e.g. [/nestedType]) — reachable without --verbose #382

Description

Summary

Agent text is rendered into a Rich Panel without escaping, so any prompt or plan document containing a bracketed token that parses as a Rich closing tag — [/nestedType], [/path], [/bold] — raises MarkupError and kills the run.

This is not a cosmetic logging failure. It killed a long-running workflow mid-execution and then killed both resume attempts at the identical byte offset, because the offending text is checkpointed and replayed verbatim. Without hand-editing the checkpoint the run was permanently unresumable.

rich.errors.MarkupError: closing tag '[/{nestedType}...]' at position 145705 doesn't match any open tag

The trigger was ordinary technical prose in a plan document describing an Azure RBAC permission format:

{provider}/{type}[/{nestedType}...]/read

Nothing about that is malformed. It is what the permission format genuinely looks like.

It is worse than "verbose-only"

There are two unescaped sinks, and the second is not gated on verbosity:

# src/conductor/cli/run.py:373-383
should_console = is_verbose() and is_full()
should_file = _file_console is not None
if not should_console and not should_file:
    return

if should_console:
    _verbose_console.print(Panel(content, title=f"[cyan]{title}[/cyan]", border_style="dim"))

# File always gets full untruncated content
if _file_console is not None:
    _file_console.print(Panel(content, title=title, border_style="dim"))   # <-- line 383

_file_console is created by init_file_logging, which is called from exactly one condition in each of run and resume:

# src/conductor/cli/run.py:1641 and :2163
if log_file is not None:
    init_file_logging(log_file)

No verbosity check anywhere on that path. --log-file alone is sufficient to arm the crash, including under --silent. Since --log-file auto is the natural way to keep a record of a long background run — and is what the Octane workflow skills recommend — this is reachable from the configuration a user is most likely to choose for exactly the long, expensive runs where losing the work hurts most.

Path to the sink

The content is the rendered prompt, which embeds user-supplied plan/workflow text:

executor/agent.py:308   _verbose_log_section(f"Prompt for '{agent.name}'", rendered_prompt)
executor/agent.py:37    verbose_log_section(title, content)
cli/run.py:359          verbose_log_section(...)
cli/run.py:383          _file_console.print(Panel(content, ...))     # unescaped

So the crash is driven by user data, not by anything conductor controls. Any plan doc, workflow file, or agent response containing [/...] is a live grenade — and a reviewer agent quoting a snippet back is enough to detonate it on a run whose plan was clean.

Reproduction

Standalone, no conductor run required. Case 1 is the exact construction from run.py:383:

import io
from rich.console import Console
from rich.panel import Panel

CONTENT = "Permission format: {provider}/{type}[/{nestedType}...]/read"
buf = io.StringIO()
c = Console(file=buf, no_color=True, highlight=False, width=200)
c.print(Panel(CONTENT, title="Prompt for 'coder'", border_style="dim"))
MarkupError: closing tag '[/{nestedType}...]' at position 36 doesn't match any open tag

Same error class and message shape as production. Minimal triggers, all confirmed raising:

input result
[/nestedType] MarkupError: ... doesn't match any open tag
[/] MarkupError: ... has nothing to close
[/bold] MarkupError: ... doesn't match any open tag
text [/foo] more MarkupError at position 5

A bare opening bracket is harmless; it is specifically the closing-tag form that raises.

Fix

Two one-line changes, both verified against the repro above to raise nothing and preserve the literal text byte-for-byte:

1. markup=False on the file console (run.py:127). The log file is no_color=True plain text — it has no use for markup interpretation, so this loses nothing:

_file_console = Console(file=_file_handle, no_color=True, highlight=False, width=200, markup=False)

2. rich.markup.escape() on agent content at the call site (run.py:379), for the console path, where markup is genuinely wanted for conductor's own styling but must not be honoured inside untrusted agent text. Note title is conductor-controlled and can keep its [cyan]; only content needs escaping. cli/doctor.py:17 already imports escape, so the dependency and idiom are established in the codebase.

I would suggest both rather than either — they defend different sinks, and (1) is the one that closes the non-verbose exposure.

Worth a wider audit too: this is the instance that bit me, but any Panel(...)/print(...) fed agent- or user-derived text has the same shape.

Workaround, for anyone hitting this mid-run

The offending text is in the checkpoint, so a plain resume re-crashes at the same offset. Rewrite the bracket form in the checkpoint JSON (e.g. [/x](/x)) and then resume. That got my run moving again.

Environment

Conductor  v0.1.26   (main @ 4a4bd57)
rich       14.3.1
Python     3.14.5    (uv-managed)
OS         Windows 11 (10.0.26200)

Not Windows-specific as far as I can tell — nothing in the parse path is platform-dependent — but I have only reproduced it on Windows.

Related

Same run, different failure: #342 / #381 (UnicodeEncodeError in JSON output). Worth stating explicitly that these are not the same bug and should not be fixed together: #342 is an encoding failure at the byte-write boundary, this is a parse failure at the markup boundary. Neither fix prevents the other. I mention it only because both surfaced from the same long run and it would be easy to conflate them.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions