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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`amplifier-agent run --prompt-file <path>`,** a second transport for the prompt. The
positional `PROMPT` argument remains valid and unchanged; the two are mutually exclusive.
File contents are decoded as UTF-8 and delivered verbatim, with no stripping and no newline
translation. Supplying both raises `argv_prompt_conflict`; an unreadable or non-UTF-8 file
raises `argv_prompt_file_unreadable`. Both are §4.1 envelopes with exit 2, matching the
existing argv-validation convention.

argv is a bounded channel: Linux caps a single argv element at 131072 bytes and Windows caps
the whole command line at 32767 chars. Past either, `execve` fails with `E2BIG` before the
engine boots, and a prompt is unbounded caller data. The wrapper contract already spilled the
much smaller MCP config to a file for exactly this reason; the prompt did not.

### Fixed

- **Wrappers now emit `--` before the positional prompt.** A prompt beginning with `-` was
parsed as an option, so the turn died with exit 2 and `No such option` before the engine
booted. The separator is emitted unconditionally rather than only for `-`-leading prompts.
Affects both the Python and TypeScript wrappers.

- **Wrappers spill a prompt of 16384 UTF-8 bytes or more to a `0600` file** and pass
`--prompt-file`, so a large prompt can no longer overflow the OS argv limit. The threshold is
measured on encoded byte length, not character count, because the OS limits are byte limits.

- **The Python wrapper's MCP config spill now writes with an explicit UTF-8 encoding.** It used
text mode with no encoding, which inherits the locale codepage — cp1252 on Windows — while the
reader opens the file as `utf-8-sig`. A non-ASCII server config would either fail to write or
fail to decode, and the reader turns a decode failure into a warning and reports no configured
servers. The TypeScript wrapper was already correct.

## [0.16.0] — 2026-08-25

### Changed
Expand Down
21 changes: 17 additions & 4 deletions docs/spec/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,26 @@ streams. Do not add a check for it.

### Prompt discipline

The prompt has two mutually exclusive transports: the positional `PROMPT` argument and
`--prompt-file <path>`. Both are valid at every size.

```
PROMPT omitted, stdin IS a TTY -> stderr: "Missing argument 'PROMPT'." exit 2
PROMPT omitted, stdin NOT a TTY -> stderr line, exit 2:
[error] prompt_required: pass prompt as argument: `amplifier-agent run "..."`.
PROMPT given, --prompt-file absent -> the positional is the prompt
--prompt-file given, PROMPT absent -> file contents are the prompt
BOTH given -> envelope, argv_prompt_conflict exit 2
--prompt-file unreadable / not UTF-8 -> envelope, argv_prompt_file_unreadable exit 2

PROMPT omitted, stdin IS a TTY -> stderr: "Missing argument 'PROMPT'." exit 2
PROMPT omitted, stdin NOT a TTY -> stderr line, exit 2:
[error] prompt_required: pass prompt as argument: `amplifier-agent run "..."`,
or from a file: `amplifier-agent run --prompt-file <path>`.
```

The non-TTY branch writes a bare stderr line, not an envelope.
The non-TTY branch writes a bare stderr line. The two `--prompt-file` rejections write a
§4.1 envelope, matching the existing argv-validation convention.

File contents are decoded as `utf-8` (not `utf-8-sig`) and delivered verbatim: no
stripping, no newline translation.

### Verbosity and approval resolution

Expand Down
28 changes: 27 additions & 1 deletion docs/spec/wrapper-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,13 @@ run
[--display text|ndjson] (only when explicitly set)
[--workspace <slug>] (only when set and non-empty)
-y | -n | (nothing) (approval policy)
<prompt> (final positional)
--prompt-file <path> (when the prompt was spilled)
-- <prompt> (otherwise; the -- separator is ALWAYS emitted)
```

Exactly one of the last two lines is emitted, never both. The `--` separator is emitted
unconditionally, not only when the prompt begins with `-`.

argv assembly must be a pure transformation of already-resolved inputs. Spill file writing,
environment resolution, and capability composition happen before it, not inside it.

Expand Down Expand Up @@ -212,6 +216,28 @@ An empty or absent map produces no file and no path. When a path was produced, t
up through its own config discovery. Spill file cleanup is an idempotent unlink that tolerates a
missing file, performed on every iterator exit path and again on cancellation.

### Prompt spill

A prompt at or above the threshold is spilled to a file and passed as `--prompt-file <path>`, so a
large prompt cannot overflow the OS argv limit.

```
threshold 16384 bytes, measured on the UTF-8 encoded length, not the character count
base dir $XDG_RUNTIME_DIR/amplifier-agent (typically tmpfs on Linux)
<system temp dir>/amplifier-agent (fallback)
path <base>/<sessionId>/prompt.txt directory mode 0700, file mode 0600
content the prompt text verbatim, UTF-8, no newline translation
```

Below the threshold the prompt stays positional, behind the `--` separator. Both transports are
valid at every size; a wrapper MAY spill unconditionally.

The file is written and closed before the subprocess is spawned. Cleanup is the same idempotent
unlink as the MCP spill, on the same exit paths.

Mode bits are POSIX-only. On Windows the file's confidentiality rests on the per-user ACL of the
system temp directory instead.

## Session handle lifecycle

Creating a handle does no subprocess work beyond the version probe: it validates lifecycle, rejects
Expand Down
47 changes: 46 additions & 1 deletion src/amplifier_agent_cli/modes/single_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,13 @@ async def _execute_turn(spec: _TurnSpec) -> dict[str, Any]:

@click.command()
@click.argument("prompt", required=False, default=None)
@click.option(
"--prompt-file",
"prompt_file",
default=None,
type=click.Path(),
help="Read the prompt from a UTF-8 file instead of the positional argument.",
)
@click.option("--session-id", default=None, help="Session ID to resume or tag.")
@click.option("--resume", is_flag=True, default=False, help="Resume an existing session.")
@click.option("--fresh", is_flag=True, default=False, help="Force a fresh session (discard saved state).")
Expand Down Expand Up @@ -640,6 +647,7 @@ async def _execute_turn(spec: _TurnSpec) -> dict[str, Any]:
)
def run(
prompt: str | None,
prompt_file: str | None,
session_id: str | None,
resume: bool,
fresh: bool,
Expand Down Expand Up @@ -700,11 +708,48 @@ def run(
raise click.UsageError("--resume and --fresh are mutually exclusive")

# (3) Prompt discipline.
#
# The prompt has two mutually exclusive transports: the positional argument
# and --prompt-file. The file path exists because argv is a bounded channel:
# Linux caps one argv element at MAX_ARG_STRLEN (131072 bytes) and Windows
# caps the whole command line at 32767 chars, so a large turn context cannot
# ride on argv at all. A file also sidesteps click's option parsing, which
# would otherwise read a '-'-leading prompt as a flag.
if prompt is not None and prompt_file is not None:
# Two sources of truth for one field is ambiguous. Silently preferring
# one would hide the calling host's bug, so reject instead.
_emit_argv_envelope(
"argv_prompt_conflict",
"Both a positional PROMPT and --prompt-file were supplied; the prompt has exactly one source.",
exit_code=2,
remediation="Pass the prompt EITHER as the positional argument OR via --prompt-file, not both.",
)
return # unreachable; _emit_argv_envelope calls sys.exit

if prompt_file is not None:
# encoding="utf-8" is REQUIRED, not stylistic: text mode without it
# inherits locale.getencoding(), which is cp1252 on Windows, and this
# file is machine-written UTF-8. Plain "utf-8" (not "utf-8-sig") so a
# legitimate leading U+FEFF in caller text survives instead of being
# silently swallowed. Content is delivered verbatim -- no strip, no
# transform; the prompt is caller data.
try:
prompt = Path(prompt_file).expanduser().read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
_emit_argv_envelope(
"argv_prompt_file_unreadable",
f"Could not read --prompt-file {prompt_file!r}: {exc}",
exit_code=2,
remediation="Ensure the path exists, is readable by this process, and contains UTF-8 encoded text.",
)
return # unreachable; _emit_argv_envelope calls sys.exit

if prompt is None:
if is_stdin_tty():
raise click.UsageError("Missing argument 'PROMPT'.")
click.echo(
'[error] prompt_required: pass prompt as argument: `amplifier-agent run "..."`.',
'[error] prompt_required: pass prompt as argument: `amplifier-agent run "..."`, '
"or from a file: `amplifier-agent run --prompt-file <path>`.",
err=True,
)
sys.exit(2)
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/suites/prompt_input/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Prompt-input suite: how a prompt reaches the engine, regardless of its size or shape."""
Loading