1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
//! Prompt composition, `{placeholder}` substitution/validation, and the inline-prompt size guard,
//! split out of `command.rs` to keep that file under the line-count gate.
use std::fmt::Write as _;
use super::repo_dir_name;
use crate::routines::agents::AgentCommand;
use crate::routines::flags::{list_flags, FlagScope};
use crate::routines::model::Routine;
/// Compose the `prompt.compiled.local.md` body: a repositories-as-context preamble, an optional
/// `## Goal` section, a routine-origin disclosure block, the prompt, and an "Open flags" section.
/// When the routine lists no repositories the preamble omits the "already cloned" sentence and
/// its (otherwise empty) bullet list, so the agent never sees a dangling header promising a repo
/// list with nothing under it.
pub(crate) fn compose_prompt(routine: &Routine) -> String {
let mut body = String::from("# Workbench\n");
if routine.repositories.is_empty() {
body.push_str("You are working in an empty directory.\n");
} else {
// These repos are pre-cloned by `build_routine_command` (via `clone_repository_stmts`,
// #466) before the agent ever launches, so the preamble points at where they already
// live instead of instructing the agent to clone them itself.
body.push_str(
"These repositories are already cloned into the workbench — cd into them, don't re-clone:\n",
);
for repo in &routine.repositories {
let dir = repo_dir_name(&repo.repository);
// `write!` into the existing `String` directly rather than `format!` + `push_str`,
// which would allocate a throwaway `String` per repository just to copy it into
// `body` immediately after. Writing to a `String` is infallible, so the `Result` is
// deliberately discarded.
match &repo.branch {
Some(branch) => {
let _ = writeln!(body, "- ./{dir} — {} (branch {branch})", repo.repository);
}
None => {
let _ = writeln!(body, "- ./{dir} — {}", repo.repository);
}
}
}
}
// A short "why" preamble, when set, so the agent has the routine's intent before the task.
if let Some(goal) = routine
.goal
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
body.push_str("\n## Goal\n");
body.push_str(goal);
body.push('\n');
}
body.push_str("\n## Routine origin disclosure\n\n");
body.push_str("You act on behalf of the moadim routine named below. In every external, outward-facing communication you produce — GitHub issues, pull requests and comments; Slack messages; emails; any channel a human or third-party system receives — you MUST disclose that the action originates from this moadim routine, naming it.\n\n");
let _ = writeln!(body, "Routine name: {}", routine.title);
body.push_str("\n---\n");
body.push_str(&routine.prompt);
body.push('\n');
let flags = list_flags(&crate::routine_storage::routine_rel_dir(routine));
if !flags.is_empty() {
body.push_str("\n---\n# Open flags\n\nRaised on a previous run and not yet resolved:\n\n");
for flag in &flags {
let scope = match flag.scope {
FlagScope::General => "general",
FlagScope::Local => "local",
};
let _ = writeln!(
body,
"- **{}** ({scope}): {}",
flag.category, flag.description
);
}
}
body
}
/// Substitute `{workbench}`, `{prompt_file}`, and `{prompt}` placeholders in `s`.
///
/// `{prompt}` expands to a shell command substitution that reads `prompt.md` from the agent's
/// cwd (the workbench), so the full prompt is passed as a single argument to the agent process.
#[allow(
clippy::literal_string_with_formatting_args,
reason = "these are literal `String::replace` placeholder tokens, not `format!`-family arguments — there is no formatting macro here to move them into"
)]
pub(crate) fn substitute(template: &str, workbench: &str, prompt_file: &str) -> String {
template
.replace("{workbench}", workbench)
.replace("{prompt_file}", prompt_file)
.replace("{prompt}", r#""$(cat prompt.md)""#)
}
/// The placeholder tokens [`substitute`] understands.
const KNOWN_PLACEHOLDERS: [&str; 3] = ["{workbench}", "{prompt_file}", "{prompt}"];
/// Return the placeholder-style `{name}` tokens in `arg`.
///
/// A token is a `{`, *not* immediately preceded by `$`, wrapping a lowercase identifier
/// (`[a-z][a-z_]*`), closed by the next `}`. This shape deliberately matches the known
/// placeholders and nothing else: shell constructs like `${HOME}`, `{}`, `{0}`, or `{print $1}`
/// are ignored, so only genuine placeholder typos (`{prompt_fil}`, `{wokbench}`) surface.
pub(crate) fn placeholder_tokens(arg: &str) -> Vec<String> {
let bytes = arg.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' && (i == 0 || bytes[i - 1] != b'$') {
if let Some(rel) = arg[i + 1..].find('}') {
let inner = &arg[i + 1..i + 1 + rel];
if inner.starts_with(|ch: char| ch.is_ascii_lowercase())
&& inner.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_')
{
out.push(format!("{{{inner}}}"));
}
i += 1 + rel + 1;
continue;
}
}
i += 1;
}
out
}
/// Validate that an agent's `args` can actually deliver a prompt and carry no typo'd placeholder.
///
/// Two silent fire-time failures are caught up front (#322):
///
/// * **Typo'd placeholder.** A token like `{prompt_fil}` is left untouched by [`substitute`] and
/// reaches the agent as a literal argument; the task never runs. Any placeholder-style token
/// outside [`KNOWN_PLACEHOLDERS`] is rejected, naming the offender.
/// * **Missing prompt.** If no arg contains `{prompt}` or `{prompt_file}`, the composed prompt is
/// never passed and the agent launches with no task, burning a full run until the watchdog reaps
/// it. At least one prompt placeholder is therefore required.
pub(crate) fn validate_placeholders(args: &[String]) -> Result<(), String> {
for arg in args {
for token in placeholder_tokens(arg) {
if !KNOWN_PLACEHOLDERS.contains(&token.as_str()) {
return Err(format!(
"unknown placeholder {token} in args; supported placeholders are {}",
KNOWN_PLACEHOLDERS.join(", ")
));
}
}
}
let delivers_prompt = args
.iter()
.any(|arg| arg.contains("{prompt}") || arg.contains("{prompt_file}"));
if !delivers_prompt {
return Err(
"args must include a prompt placeholder ({prompt} or {prompt_file}); \
otherwise the agent launches with no task"
.to_string(),
);
}
Ok(())
}
/// Conservative cap on a single inlined `{prompt}` argument, matching Linux's
/// `MAX_ARG_STRLEN` (`32 * PAGE_SIZE` = 128 KiB on the common 4 KiB page size) — the
/// tighter of the two platform limits an inlined prompt is exposed to (macOS's
/// combined arg+env budget, `kern.argmax`, is roughly double). An agent using
/// `{prompt_file}` instead is never subject to this: the prompt reaches the process
/// as a file path, not a single oversized argv entry.
pub(crate) const MAX_INLINE_PROMPT_BYTES: usize = 128 * 1024;
/// Byte length of `routine`'s composed prompt when `agent` would inline it into a
/// single process argument that exceeds [`MAX_INLINE_PROMPT_BYTES`]; `None` when the
/// agent doesn't use `{prompt}` at all, or the composed prompt fits.
///
/// Only agents whose `args` template contains the literal `{prompt}` placeholder are
/// at risk (see [`substitute`]) — `claude`, the shipped default, is one of them
/// (#443). A large composed prompt (routine `prompt` + the repositories preamble +
/// accumulated open flags, see [`compose_prompt`]) then makes the `execve` inside the
/// launch's detached tmux session fail with `E2BIG`, silently no-oping the run
/// instead of erroring anywhere visible.
pub(crate) fn inline_prompt_overflow(routine: &Routine, agent: &AgentCommand) -> Option<usize> {
if !agent.args.iter().any(|arg| arg.contains("{prompt}")) {
return None;
}
let len = compose_prompt(routine).len();
(len > MAX_INLINE_PROMPT_BYTES).then_some(len)
}