1use std::path::Path;
4
5use askama::Template;
6
7use super::model::ProtocolRequestProfile;
8use super::schema::agent_response_json_schema_json;
9
10const PROTOCOL_INSTRUCTIONS_MARKER: &str = "Structured response protocol:";
11const PROTOCOL_REFRESH_REMINDER_MARKER: &str = "Protocol refresh reminder:";
12const REPAIR_RESPONSE_PREVIEW_MAX_CHARS: usize = 500;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ProtocolSchemaInstructionMode {
18 PromptSchema,
21 TransportSchema,
24}
25
26impl ProtocolSchemaInstructionMode {
27 fn includes_response_json_schema(self) -> bool {
30 matches!(self, Self::PromptSchema)
31 }
32}
33
34#[must_use]
45pub fn prepend_protocol_instructions(
46 prompt: &str,
47 profile: ProtocolRequestProfile,
48 schema_instruction_mode: ProtocolSchemaInstructionMode,
49 workspace_root: &Path,
50) -> String {
51 if prompt.contains(PROTOCOL_INSTRUCTIONS_MARKER) {
52 return prompt.to_string();
53 }
54
55 let protocol_usage_instructions = render_protocol_usage_instructions(profile);
56 let workspace_root = workspace_root.display().to_string();
57 if !schema_instruction_mode.includes_response_json_schema() {
58 let template = ProtocolInstructionPolicyPromptTemplate {
59 prompt,
60 protocol_usage_instructions: &protocol_usage_instructions,
61 workspace_root: &workspace_root,
62 };
63
64 return render_template("protocol_instruction_policy_prompt.md", &template);
65 }
66
67 let response_json_schema = agent_response_json_schema_json();
68 let template = ProtocolInstructionPromptTemplate {
69 prompt,
70 protocol_usage_instructions: &protocol_usage_instructions,
71 response_json_schema: &response_json_schema,
72 workspace_root: &workspace_root,
73 };
74
75 render_template("protocol_instruction_prompt.md", &template)
76}
77
78#[must_use]
85pub fn prepend_protocol_refresh_reminder(
86 prompt: &str,
87 profile: ProtocolRequestProfile,
88 workspace_root: &Path,
89) -> String {
90 if prompt.contains(PROTOCOL_INSTRUCTIONS_MARKER)
91 || prompt.contains(PROTOCOL_REFRESH_REMINDER_MARKER)
92 {
93 return prompt.to_string();
94 }
95
96 let protocol_refresh_instructions = render_protocol_refresh_instructions(profile);
97 let workspace_root = workspace_root.display().to_string();
98 let template = ProtocolRefreshPromptTemplate {
99 prompt,
100 protocol_refresh_instructions: &protocol_refresh_instructions,
101 workspace_root: &workspace_root,
102 };
103
104 render_template("protocol_refresh_prompt.md", &template)
105}
106
107#[must_use]
113pub fn build_protocol_repair_prompt(parse_error: &str, malformed_response: &str) -> String {
114 let response_json_schema = agent_response_json_schema_json();
115 let response_preview = truncate_preview(malformed_response, REPAIR_RESPONSE_PREVIEW_MAX_CHARS);
116 let template = ProtocolRepairPromptTemplate {
117 parse_error,
118 response_json_schema: &response_json_schema,
119 response_preview: &response_preview,
120 };
121
122 render_template("protocol_repair_prompt.md", &template)
123}
124
125#[derive(Template)]
128#[template(path = "protocol_instruction_policy_prompt.md", escape = "none")]
129struct ProtocolInstructionPolicyPromptTemplate<'a> {
130 prompt: &'a str,
131 protocol_usage_instructions: &'a str,
132 workspace_root: &'a str,
133}
134
135#[derive(Template)]
137#[template(path = "protocol_instruction_prompt.md", escape = "none")]
138struct ProtocolInstructionPromptTemplate<'a> {
139 prompt: &'a str,
140 protocol_usage_instructions: &'a str,
141 response_json_schema: &'a str,
142 workspace_root: &'a str,
143}
144
145#[derive(Template)]
147#[template(path = "protocol_refresh_prompt.md", escape = "none")]
148struct ProtocolRefreshPromptTemplate<'a> {
149 prompt: &'a str,
150 protocol_refresh_instructions: &'a str,
151 workspace_root: &'a str,
152}
153
154#[derive(Template)]
156#[template(path = "protocol_repair_prompt.md", escape = "none")]
157struct ProtocolRepairPromptTemplate<'a> {
158 parse_error: &'a str,
159 response_json_schema: &'a str,
160 response_preview: &'a str,
161}
162
163#[derive(Template)]
165#[template(path = "protocol_instruction_session_turn_usage.md", escape = "none")]
166struct ProtocolInstructionSessionTurnUsageTemplate;
167
168#[derive(Template)]
170#[template(path = "protocol_instruction_utility_prompt_usage.md", escape = "none")]
171struct ProtocolInstructionUtilityPromptUsageTemplate;
172
173#[derive(Template)]
175#[template(path = "protocol_refresh_session_turn_instruction.md", escape = "none")]
176struct ProtocolRefreshSessionTurnInstructionTemplate;
177
178#[derive(Template)]
180#[template(
181 path = "protocol_refresh_utility_prompt_instruction.md",
182 escape = "none"
183)]
184struct ProtocolRefreshUtilityPromptInstructionTemplate;
185
186fn render_protocol_usage_instructions(profile: ProtocolRequestProfile) -> String {
188 if matches!(profile, ProtocolRequestProfile::SessionTurn) {
189 return render_template(
190 "protocol_instruction_session_turn_usage.md",
191 &ProtocolInstructionSessionTurnUsageTemplate,
192 );
193 }
194
195 render_template(
196 "protocol_instruction_utility_prompt_usage.md",
197 &ProtocolInstructionUtilityPromptUsageTemplate,
198 )
199}
200
201fn render_protocol_refresh_instructions(profile: ProtocolRequestProfile) -> String {
203 if matches!(profile, ProtocolRequestProfile::SessionTurn) {
204 return render_template(
205 "protocol_refresh_session_turn_instruction.md",
206 &ProtocolRefreshSessionTurnInstructionTemplate,
207 );
208 }
209
210 render_template(
211 "protocol_refresh_utility_prompt_instruction.md",
212 &ProtocolRefreshUtilityPromptInstructionTemplate,
213 )
214}
215
216fn render_template(template_name: &str, template: &impl Template) -> String {
218 let rendered = match template.render() {
219 Ok(rendered) => rendered,
220 Err(error) => format!("Failed to render `{template_name}`: {error}"),
221 };
222
223 rendered.trim_end().to_string()
224}
225
226fn truncate_preview(raw: &str, max_chars: usize) -> String {
228 let preview: String = raw.chars().take(max_chars).collect();
229 let total_chars = raw.chars().count();
230
231 if total_chars <= max_chars {
232 return preview;
233 }
234
235 format!("{preview}\n... [{} more chars]", total_chars - max_chars)
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn test_workspace_root() -> &'static Path {
244 Path::new("/tmp/agentty-wt/session-1")
245 }
246
247 fn normalize_prompt(prompt: &str) -> String {
249 prompt.split_whitespace().collect::<Vec<_>>().join(" ")
250 }
251
252 #[test]
253 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
255 let prompt = "Implement feature";
257
258 let rendered_prompt = prepend_protocol_instructions(
260 prompt,
261 ProtocolRequestProfile::SessionTurn,
262 ProtocolSchemaInstructionMode::PromptSchema,
263 test_workspace_root(),
264 );
265
266 let normalized_prompt = normalize_prompt(&rendered_prompt);
267 let protocol_position = rendered_prompt
268 .find("Structured response protocol:")
269 .expect("protocol marker should be present");
270 let schema_position = rendered_prompt
271 .find("Authoritative JSON Schema:")
272 .expect("schema should be present");
273 let user_prompt_position = rendered_prompt
274 .rfind(prompt)
275 .expect("user prompt should be present");
276
277 assert!(rendered_prompt.contains("File path output requirements:"));
279 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
280 assert!(protocol_position < schema_position);
281 assert!(schema_position < user_prompt_position);
282 assert!(rendered_prompt.contains("`/tmp/agentty-wt/session-1`"));
283 assert!(normalized_prompt.contains("process working directory"));
284 assert!(normalized_prompt.contains("everything outside it is read-only"));
285 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
286 assert!(rendered_prompt.contains("`path:line:column`"));
287 assert!(normalized_prompt.contains("absolute paths, `file://` URIs, or `../` prefixes"));
288 assert!(normalized_prompt.contains("Git commands must be read-only"));
289 assert!(normalized_prompt.contains("Never run mutating commands"));
290 assert!(rendered_prompt.contains("`git worktree remove`"));
291 assert!(rendered_prompt.contains("`cd`, `git -C`"));
292 assert!(rendered_prompt.contains("Quality check requirements:"));
293 assert!(rendered_prompt.contains("repository-defined checks"));
294 assert!(normalized_prompt.contains("affected dependencies and dependents"));
295 assert!(normalized_prompt.contains("full repository test/check suite"));
296 assert!(normalized_prompt.contains("session-created temporary scripts and files"));
297 assert!(rendered_prompt.contains("Structured response protocol:"));
298 assert!(normalized_prompt.contains("exactly one JSON object"));
299 assert!(normalized_prompt.contains("without markdown fences or surrounding prose"));
300 assert!(normalized_prompt.contains("Follow this JSON Schema exactly"));
301 assert!(normalized_prompt.contains("titles and descriptions are authoritative"));
302 assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
303 assert!(
304 rendered_prompt
305 .contains("______________________________________________________________________")
306 );
307 assert!(!rendered_prompt.contains("{# task separator #}"));
308 assert!(rendered_prompt.contains("For this session turn:"));
309 assert!(rendered_prompt.contains("```mermaid"));
310 assert!(normalized_prompt.contains("diagram only in `answer`"));
311 assert!(normalized_prompt.contains("opening fence starts in column 1"));
312 assert!(normalized_prompt.contains("exactly three backticks"));
313 assert!(
314 normalized_prompt.contains("Other fences, indented blocks, and plain-text Mermaid")
315 );
316 assert!(normalized_prompt.contains("`graph`/`flowchart` with `TD`, `TB`, or `LR`"));
317 assert!(normalized_prompt.contains("32 plain-ASCII characters"));
318 assert!(normalized_prompt.contains("at most 16 nodes and 24 edges"));
319 assert!(normalized_prompt.contains("at most 4 sequence participants"));
320 assert!(normalized_prompt.contains("double-width glyphs suppress the preview"));
321 assert!(normalized_prompt.contains("feedback edge as a separate return row"));
322 assert!(normalized_prompt.contains("fall back to plain fenced code"));
323 assert!(normalized_prompt.contains("Do not create commits; do not suggest creating them"));
324 assert!(normalized_prompt.contains("Leave `subtasks` empty unless"));
325 assert!(normalized_prompt.contains("Emit `review_comment_outcomes` only"));
326 assert!(normalized_prompt.contains("otherwise use an empty array"));
327 assert!(rendered_prompt.contains("\"answer\""));
328 assert!(rendered_prompt.contains("\"questions\""));
329 assert!(rendered_prompt.contains("\"title\""));
330 assert!(rendered_prompt.contains("\"description\""));
331 assert!(rendered_prompt.ends_with(prompt));
332 }
333
334 #[test]
335 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
338 let prompt = "Implement feature";
340
341 let rendered_prompt = prepend_protocol_instructions(
343 prompt,
344 ProtocolRequestProfile::SessionTurn,
345 ProtocolSchemaInstructionMode::TransportSchema,
346 test_workspace_root(),
347 );
348
349 let normalized_prompt = normalize_prompt(&rendered_prompt);
350
351 assert!(rendered_prompt.contains("Structured response protocol:"));
353 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
354 assert!(rendered_prompt.contains("`/tmp/agentty-wt/session-1`"));
355 assert!(normalized_prompt.contains("everything outside it is read-only"));
356 assert!(rendered_prompt.contains("provider enforces the response JSON schema"));
357 assert!(normalized_prompt.contains("exactly one JSON object"));
358 assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
359 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
360 assert!(rendered_prompt.ends_with(prompt));
361 }
362
363 #[test]
364 fn test_prepend_protocol_instructions_is_idempotent() {
366 let prompt = prepend_protocol_instructions(
368 "Implement feature",
369 ProtocolRequestProfile::SessionTurn,
370 ProtocolSchemaInstructionMode::PromptSchema,
371 test_workspace_root(),
372 );
373
374 let rendered_prompt = prepend_protocol_instructions(
376 &prompt,
377 ProtocolRequestProfile::UtilityPrompt,
378 ProtocolSchemaInstructionMode::TransportSchema,
379 test_workspace_root(),
380 );
381
382 assert_eq!(rendered_prompt, prompt);
384 }
385
386 #[test]
387 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
390 let prompt = "Generate title";
392
393 let rendered_prompt = prepend_protocol_instructions(
395 prompt,
396 ProtocolRequestProfile::UtilityPrompt,
397 ProtocolSchemaInstructionMode::PromptSchema,
398 test_workspace_root(),
399 );
400
401 assert!(rendered_prompt.contains("Structured response protocol:"));
403 assert!(
404 rendered_prompt
405 .contains("______________________________________________________________________")
406 );
407 assert!(rendered_prompt.contains("For this one-shot utility prompt"));
408 assert!(!rendered_prompt.contains("For this session turn:"));
409 assert!(!rendered_prompt.contains("mermaid"));
410 assert!(rendered_prompt.contains(
411 r#"{"answer":"...","questions":[],"review_comment_outcomes":[],"summary":null}"#
412 ));
413 assert!(rendered_prompt.contains("\"review_comment_outcomes\""));
414 assert!(rendered_prompt.contains("\"summary\""));
415 assert!(rendered_prompt.ends_with(prompt));
416 }
417
418 #[test]
419 fn test_prepend_protocol_instructions_preserves_prompt_placeholders() {
422 let prompt = "Keep these literal: {{ response_json_schema }} {{ \
424 protocol_usage_instructions }} {{ workspace_root }}";
425
426 let rendered_prompt = prepend_protocol_instructions(
428 prompt,
429 ProtocolRequestProfile::UtilityPrompt,
430 ProtocolSchemaInstructionMode::PromptSchema,
431 test_workspace_root(),
432 );
433
434 assert!(rendered_prompt.ends_with(prompt));
436 }
437
438 #[test]
439 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
442 let prompt = "Continue the implementation";
444
445 let rendered_prompt = prepend_protocol_refresh_reminder(
447 prompt,
448 ProtocolRequestProfile::SessionTurn,
449 test_workspace_root(),
450 );
451 let normalized_prompt = normalize_prompt(&rendered_prompt);
452
453 assert!(rendered_prompt.contains("Protocol refresh reminder:"));
455 assert!(rendered_prompt.contains("repository-root-relative POSIX"));
456 assert!(normalized_prompt.contains("only read-only git commands; never mutating ones"));
457 assert!(rendered_prompt.contains("inside `/tmp/agentty-wt/session-1`"));
458 assert!(normalized_prompt.contains("everything outside this workspace root is read-only"));
459 assert!(normalized_prompt.contains("Keep Mermaid in `answer`"));
460 assert!(normalized_prompt.contains("fences lacking the `mermaid` info string"));
461 assert!(
462 rendered_prompt
463 .contains("______________________________________________________________________")
464 );
465 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
466 assert!(rendered_prompt.ends_with(prompt));
467 }
468
469 #[test]
470 fn test_prepend_protocol_refresh_reminder_preserves_prompt_placeholders() {
473 let prompt = "Keep this literal: {{ protocol_refresh_instructions }} {{ workspace_root }}";
475
476 let rendered_prompt = prepend_protocol_refresh_reminder(
478 prompt,
479 ProtocolRequestProfile::SessionTurn,
480 test_workspace_root(),
481 );
482
483 assert!(rendered_prompt.ends_with(prompt));
485 }
486
487 #[test]
488 fn test_prepend_protocol_refresh_reminder_uses_utility_profile() {
491 let prompt = "Generate another title";
493
494 let rendered_prompt = prepend_protocol_refresh_reminder(
496 prompt,
497 ProtocolRequestProfile::UtilityPrompt,
498 test_workspace_root(),
499 );
500
501 assert!(rendered_prompt.contains("bootstrapped one-shot JSON object shape"));
503 assert!(!rendered_prompt.contains("`review_comment_outcomes`"));
504 assert!(!rendered_prompt.contains("```mermaid"));
505 assert!(rendered_prompt.ends_with(prompt));
506 }
507
508 #[test]
509 fn test_build_protocol_repair_prompt_includes_error_and_preview() {
511 let parse_error = "response is not valid protocol JSON: invalid JSON";
513 let malformed_response = "plain text response";
514
515 let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
517
518 assert!(repair_prompt.contains(parse_error));
520 assert!(repair_prompt.contains("plain text response"));
521 assert!(repair_prompt.contains("Structured response protocol:"));
522 assert!(repair_prompt.contains("Authoritative JSON Schema:"));
523 assert!(repair_prompt.contains("\"answer\""));
524 }
525
526 #[test]
527 fn test_build_protocol_repair_prompt_preserves_response_preview_placeholders() {
530 let malformed_response = "Keep this literal: {{ response_json_schema }}";
532
533 let repair_prompt =
535 build_protocol_repair_prompt("schema validation failed", malformed_response);
536
537 assert!(repair_prompt.contains(malformed_response));
539 }
540
541 #[test]
542 fn test_build_protocol_repair_prompt_truncates_long_response() {
544 let parse_error = "schema validation failed";
546 let malformed_response = "x".repeat(1000);
547
548 let repair_prompt = build_protocol_repair_prompt(parse_error, &malformed_response);
550
551 assert!(repair_prompt.contains("500 more chars"));
553 assert!(!repair_prompt.contains(&malformed_response));
554 }
555
556 #[test]
557 fn test_build_protocol_repair_prompt_contains_protocol_marker() {
559 let repair_prompt = build_protocol_repair_prompt("error", "response");
561
562 assert!(repair_prompt.contains("Structured response protocol:"));
564 }
565
566 #[test]
567 fn test_truncate_preview_keeps_short_responses_intact() {
569 let preview = truncate_preview("short", 500);
571
572 assert_eq!(preview, "short");
574 }
575
576 #[test]
577 fn test_truncate_preview_truncates_long_responses() {
579 let long_response = "a".repeat(600);
581
582 let preview = truncate_preview(&long_response, 500);
584
585 assert!(preview.starts_with(&"a".repeat(500)));
587 assert!(preview.contains("100 more chars"));
588 }
589}