Skip to content

Commit 9144be8

Browse files
committed
fix!: file-reference prompt for oversized pastes (1-hour hang fix) (v0.4.10)
User repro'd a 1-hour silent hang on the phase 1 review stage: \`(1484s, idle 1478s) phase 1 first review: reviewer running …\` — 6 seconds of activity at spawn, then ZERO PTY bytes for 24+ minutes until they Ctrl+C'd. Root cause: the composite multi-dimension review prompt is ~5× a normal iteration prompt (5 dimensions × full review_template × substituted agent_template body) — easily 30–50 KB. drive_agent pasted it through bracketed-paste in a single write. claude's REPL input handler either silently truncates / rejects / freezes on a 30 KB+ paste; the PTY then goes completely silent because claude isn't processing any input. Fix: drive_agent's protocol preamble now switches to file-reference mode when the task prompt is >8 KiB (INLINE_PASTE_SOFT_CAP). The agent gets a tiny (<1 KB) instruction pasted into the PTY: RALPHTERM PROTOCOL … 1. Read your task instructions from this file: /workspace/.ralphterm/iteration-output/<nonce>.prompt.txt 2. Perform the task it describes. 3. Write your response to /workspace/.ralphterm/iteration-output/<nonce>.md between <<<BEGIN>>> and <<<END>>> markers. The agent reads the prompt via its own Read tool — one short request that fits any PTY buffer. We already wrote the full inline-form prompt to .ralphterm/iteration-output/<nonce>.prompt.txt for the fixtures' RALPHTERM_PROMPT_FILE env-var path, so the file is guaranteed to be there. Small prompts (implementer iterations, ~5–8 KB) keep the old inline-paste path: no extra tool call, no behaviour change. Tests: - build_prompt_includes_path_and_task_text retained, updated for the new 4-arg signature. - NEW build_prompt_uses_file_reference_for_large_prompts asserts: * pasted form < 1 KB * contains the prompt_path * does NOT contain the huge task body * contains the output_path build_inline_prompt_with_protocol extracted as a sibling helper because both the always-written-to-disk path and the small-prompt inline path use the same body.
1 parent 2ccfe69 commit 9144be8

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ralphterm"
3-
version = "0.4.9"
3+
version = "0.4.10"
44
edition = "2021"
55
rust-version = "1.85"
66
description = "Programmable PTY for the official Claude Code and Codex CLIs. Runs long multi-task plans unattended — iterates, validates, commits per task, and gates merge behind a parallel review pipeline."

src/agent_driver.rs

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,14 @@ pub async fn drive_agent(spec: AgentSpec<'_>) -> Result<AgentRun> {
121121
let prompt_path = output_dir.join(format!("{nonce}.prompt.txt"));
122122
let transcript_path = output_dir.join(format!("{nonce}.transcript.txt"));
123123

124-
let prompt = build_prompt_with_protocol(spec.task_prompt, &nonce, &output_path);
125-
std::fs::write(&prompt_path, &prompt)
124+
// ALWAYS write the full inline-form wrapped prompt to disk so the
125+
// agent (or a curious operator) can read it. The pasted prompt may
126+
// either be this full text (small prompts) or a short pointer to
127+
// this file (large prompts) — see build_prompt_with_protocol.
128+
let inline_prompt = build_inline_prompt_with_protocol(spec.task_prompt, &nonce, &output_path);
129+
std::fs::write(&prompt_path, &inline_prompt)
126130
.with_context(|| format!("write {}", prompt_path.display()))?;
131+
let prompt = build_prompt_with_protocol(spec.task_prompt, &nonce, &output_path, &prompt_path);
127132
// Touch the transcript so `tail -f` works the moment the run starts.
128133
let _ = std::fs::File::create(&transcript_path);
129134

@@ -453,7 +458,15 @@ enum DriverShutdown {
453458
Cancelled,
454459
}
455460

456-
fn build_prompt_with_protocol(task_prompt: &str, nonce: &str, output_path: &Path) -> String {
461+
/// Soft cap on inline-pasted prompts (bytes). Above this, we paste a
462+
/// short instruction that points at the prompt file on disk instead.
463+
/// 8 KiB is well under typical PTY buffer limits and observed to be a
464+
/// safe paste size for claude's REPL.
465+
const INLINE_PASTE_SOFT_CAP: usize = 8 * 1024;
466+
467+
/// The full inline form of the wrapped prompt. Always written to
468+
/// `prompt_path` so the file-reference path has something to read.
469+
fn build_inline_prompt_with_protocol(task_prompt: &str, nonce: &str, output_path: &Path) -> String {
457470
format!(
458471
"RALPHTERM PROTOCOL — you MUST follow this exactly:\n\
459472
When you have a final response for this iteration, write the response to this file:\n\
@@ -470,6 +483,40 @@ fn build_prompt_with_protocol(task_prompt: &str, nonce: &str, output_path: &Path
470483
)
471484
}
472485

486+
fn build_prompt_with_protocol(
487+
task_prompt: &str,
488+
nonce: &str,
489+
output_path: &Path,
490+
prompt_path: &Path,
491+
) -> String {
492+
// For large prompts (composite multi-dimension reviews especially),
493+
// pasting tens of kilobytes through bracketed-paste is unreliable —
494+
// user reproed a 1-hour silent hang on a ~30 KB composite prompt
495+
// (claude's REPL stopped responding after the paste; zero PTY
496+
// bytes for 24+ minutes idle). Above 8 KiB we paste a tiny
497+
// instruction that tells the agent to read the prompt from disk
498+
// instead. The agent then reads the file via its own tool, which
499+
// is a single short request that doesn't blow any input buffer.
500+
if task_prompt.len() > INLINE_PASTE_SOFT_CAP {
501+
return format!(
502+
"RALPHTERM PROTOCOL — you MUST follow this exactly:\n\n\
503+
1. Read your task instructions from this file (it's too large to paste inline):\n\
504+
{prompt}\n\
505+
2. Perform the task it describes.\n\
506+
3. When you have a final response, write it to this file:\n\
507+
{out}\n\
508+
The file MUST start with the literal line `<<<BEGIN>>>` on its own line, \
509+
followed by your response (a concise account of what was done), and end with \
510+
the literal line `<<<END>>>` on its own line. After writing the file you do \
511+
not need to print anything special — the orchestrator polls the file. \
512+
(Reference nonce: {nonce})\n",
513+
prompt = prompt_path.display(),
514+
out = output_path.display(),
515+
);
516+
}
517+
build_inline_prompt_with_protocol(task_prompt, nonce, output_path)
518+
}
519+
473520
fn make_nonce() -> String {
474521
let nanos = SystemTime::now()
475522
.duration_since(UNIX_EPOCH)
@@ -604,7 +651,12 @@ mod tests {
604651

605652
#[test]
606653
fn build_prompt_includes_path_and_task_text() {
607-
let p = build_prompt_with_protocol("DO THE THING", "abc123", Path::new("/tmp/x/abc123.md"));
654+
let p = build_prompt_with_protocol(
655+
"DO THE THING",
656+
"abc123",
657+
Path::new("/tmp/x/abc123.md"),
658+
Path::new("/tmp/x/abc123.prompt.txt"),
659+
);
608660
assert!(p.contains("/tmp/x/abc123.md"));
609661
assert!(p.contains("<<<BEGIN>>>"));
610662
assert!(p.contains("<<<END>>>"));
@@ -613,6 +665,30 @@ mod tests {
613665
assert!(p.contains("abc123"));
614666
}
615667

668+
#[test]
669+
fn build_prompt_uses_file_reference_for_large_prompts() {
670+
// Reproduces the user's 24-minute-silent-hang scenario: a
671+
// composite multi-dimension review prompt that's tens of KB.
672+
// Above INLINE_PASTE_SOFT_CAP we paste a short pointer to the
673+
// prompt file instead of the prompt body.
674+
let huge = "X".repeat(INLINE_PASTE_SOFT_CAP + 1);
675+
let p = build_prompt_with_protocol(
676+
&huge,
677+
"abc123",
678+
Path::new("/tmp/x/abc123.md"),
679+
Path::new("/tmp/x/abc123.prompt.txt"),
680+
);
681+
// Pasted form should be tiny (instruction only, no body).
682+
assert!(
683+
p.len() < 1024,
684+
"large-prompt form should be a short pointer, got {} bytes",
685+
p.len()
686+
);
687+
assert!(p.contains("/tmp/x/abc123.prompt.txt"));
688+
assert!(p.contains("/tmp/x/abc123.md"));
689+
assert!(!p.contains(&huge));
690+
}
691+
616692
#[test]
617693
fn nonce_is_unique_across_calls_within_a_process() {
618694
let a = make_nonce();

0 commit comments

Comments
 (0)