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 #[test]
248 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
250 let prompt = "Implement feature";
252
253 let rendered_prompt = prepend_protocol_instructions(
255 prompt,
256 ProtocolRequestProfile::SessionTurn,
257 ProtocolSchemaInstructionMode::PromptSchema,
258 test_workspace_root(),
259 );
260
261 assert!(rendered_prompt.contains("File path output requirements:"));
263 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
264 assert!(rendered_prompt.contains("Your workspace root is `/tmp/agentty-wt/session-1`."));
265 assert!(rendered_prompt.contains("Anything outside that"));
266 assert!(rendered_prompt.contains("root is read-only."));
267 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
268 assert!(
269 rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
270 );
271 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
272 assert!(rendered_prompt.contains("Do not run mutating git commands"));
273 assert!(rendered_prompt.contains("Quality check requirements:"));
274 assert!(rendered_prompt.contains("repository-defined quality checks"));
275 let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
276 let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
277 assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
278 assert!(rendered_prompt.contains("full repository test/check suite"));
279 assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
280 assert!(rendered_prompt.contains("Structured response protocol:"));
281 assert!(rendered_prompt.contains("Return a single JSON object"));
282 assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
283 assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
284 assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
285 assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
286 assert!(
287 rendered_prompt
288 .contains("______________________________________________________________________")
289 );
290 assert!(!rendered_prompt.contains("{# task separator #}"));
291 assert!(rendered_prompt.contains("For this session turn"));
292 assert!(rendered_prompt.contains("```mermaid"));
293 assert!(rendered_prompt.contains("put the diagram only in `answer`"));
294 assert!(normalized_rendered_prompt.contains("start the opening fence at column 1"));
295 assert!(rendered_prompt.contains("recognizes Mermaid diagrams only in this fenced"));
296 assert!(rendered_prompt.contains("Do not emit Mermaid as plain text"));
297 assert!(rendered_prompt.contains("Supported mermaid syntax"));
298 assert!(normalized_rendered_prompt.contains("Do not create commits"));
299 assert!(normalized_rendered_prompt.contains("suggest creating commits"));
300 assert!(rendered_prompt.contains("summary"));
301 assert!(rendered_prompt.contains("turn"));
302 assert!(rendered_prompt.contains("session"));
303 assert!(rendered_prompt.contains("\"answer\""));
304 assert!(rendered_prompt.contains("\"questions\""));
305 assert!(rendered_prompt.contains("\"title\""));
306 assert!(rendered_prompt.contains("\"description\""));
307 assert!(rendered_prompt.contains("summary"));
308 assert!(rendered_prompt.ends_with(prompt));
309 }
310
311 #[test]
312 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
315 let prompt = "Implement feature";
317
318 let rendered_prompt = prepend_protocol_instructions(
320 prompt,
321 ProtocolRequestProfile::SessionTurn,
322 ProtocolSchemaInstructionMode::TransportSchema,
323 test_workspace_root(),
324 );
325
326 assert!(rendered_prompt.contains("Structured response protocol:"));
328 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
329 assert!(rendered_prompt.contains("Your workspace root is `/tmp/agentty-wt/session-1`."));
330 assert!(rendered_prompt.contains("Anything outside that"));
331 assert!(rendered_prompt.contains("root is read-only."));
332 assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
333 assert!(rendered_prompt.contains("Return a single JSON object"));
334 assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
335 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
336 assert!(rendered_prompt.ends_with(prompt));
337 }
338
339 #[test]
340 fn test_prepend_protocol_instructions_is_idempotent() {
342 let prompt = prepend_protocol_instructions(
344 "Implement feature",
345 ProtocolRequestProfile::SessionTurn,
346 ProtocolSchemaInstructionMode::PromptSchema,
347 test_workspace_root(),
348 );
349
350 let rendered_prompt = prepend_protocol_instructions(
352 &prompt,
353 ProtocolRequestProfile::UtilityPrompt,
354 ProtocolSchemaInstructionMode::TransportSchema,
355 test_workspace_root(),
356 );
357
358 assert_eq!(rendered_prompt, prompt);
360 }
361
362 #[test]
363 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
366 let prompt = "Generate title";
368
369 let rendered_prompt = prepend_protocol_instructions(
371 prompt,
372 ProtocolRequestProfile::UtilityPrompt,
373 ProtocolSchemaInstructionMode::PromptSchema,
374 test_workspace_root(),
375 );
376
377 assert!(rendered_prompt.contains("Structured response protocol:"));
379 assert!(
380 rendered_prompt
381 .contains("______________________________________________________________________")
382 );
383 assert!(rendered_prompt.contains("For this one-shot utility prompt"));
384 assert!(!rendered_prompt.contains("mermaid"));
385 assert!(rendered_prompt.contains(
386 r#"{"answer":"...","questions":[],"review_comment_outcomes":[],"summary":null}"#
387 ));
388 assert!(rendered_prompt.contains("\"review_comment_outcomes\""));
389 assert!(rendered_prompt.contains("\"summary\""));
390 assert!(rendered_prompt.ends_with(prompt));
391 }
392
393 #[test]
394 fn test_prepend_protocol_instructions_preserves_prompt_placeholders() {
397 let prompt = "Keep these literal: {{ response_json_schema }} {{ \
399 protocol_usage_instructions }} {{ workspace_root }}";
400
401 let rendered_prompt = prepend_protocol_instructions(
403 prompt,
404 ProtocolRequestProfile::UtilityPrompt,
405 ProtocolSchemaInstructionMode::PromptSchema,
406 test_workspace_root(),
407 );
408
409 assert!(rendered_prompt.ends_with(prompt));
411 }
412
413 #[test]
414 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
417 let prompt = "Continue the implementation";
419
420 let rendered_prompt = prepend_protocol_refresh_reminder(
422 prompt,
423 ProtocolRequestProfile::SessionTurn,
424 test_workspace_root(),
425 );
426 let normalized_prompt = rendered_prompt
427 .split_whitespace()
428 .collect::<Vec<_>>()
429 .join(" ");
430
431 assert!(rendered_prompt.contains("Protocol refresh reminder:"));
433 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
434 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
435 assert!(rendered_prompt.contains("Do not run mutating git commands."));
436 assert!(rendered_prompt.contains("inside the workspace root `/tmp/agentty-wt/session-1`"));
437 assert!(rendered_prompt.contains("anything outside that root is read-only"));
438 assert!(normalized_prompt.contains("Mermaid diagrams must remain in `answer`"));
439 assert!(normalized_prompt.contains("fences without the `mermaid` info string"));
440 assert!(
441 rendered_prompt
442 .contains("______________________________________________________________________")
443 );
444 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
445 assert!(rendered_prompt.ends_with(prompt));
446 }
447
448 #[test]
449 fn test_prepend_protocol_refresh_reminder_preserves_prompt_placeholders() {
452 let prompt = "Keep this literal: {{ protocol_refresh_instructions }} {{ workspace_root }}";
454
455 let rendered_prompt = prepend_protocol_refresh_reminder(
457 prompt,
458 ProtocolRequestProfile::SessionTurn,
459 test_workspace_root(),
460 );
461
462 assert!(rendered_prompt.ends_with(prompt));
464 }
465
466 #[test]
467 fn test_build_protocol_repair_prompt_includes_error_and_preview() {
469 let parse_error = "response is not valid protocol JSON: invalid JSON";
471 let malformed_response = "plain text response";
472
473 let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
475
476 assert!(repair_prompt.contains(parse_error));
478 assert!(repair_prompt.contains("plain text response"));
479 assert!(repair_prompt.contains("Structured response protocol:"));
480 assert!(repair_prompt.contains("Authoritative JSON Schema:"));
481 assert!(repair_prompt.contains("\"answer\""));
482 }
483
484 #[test]
485 fn test_build_protocol_repair_prompt_preserves_response_preview_placeholders() {
488 let malformed_response = "Keep this literal: {{ response_json_schema }}";
490
491 let repair_prompt =
493 build_protocol_repair_prompt("schema validation failed", malformed_response);
494
495 assert!(repair_prompt.contains(malformed_response));
497 }
498
499 #[test]
500 fn test_build_protocol_repair_prompt_truncates_long_response() {
502 let parse_error = "schema validation failed";
504 let malformed_response = "x".repeat(1000);
505
506 let repair_prompt = build_protocol_repair_prompt(parse_error, &malformed_response);
508
509 assert!(repair_prompt.contains("500 more chars"));
511 assert!(!repair_prompt.contains(&malformed_response));
512 }
513
514 #[test]
515 fn test_build_protocol_repair_prompt_contains_protocol_marker() {
517 let repair_prompt = build_protocol_repair_prompt("error", "response");
519
520 assert!(repair_prompt.contains("Structured response protocol:"));
522 }
523
524 #[test]
525 fn test_truncate_preview_keeps_short_responses_intact() {
527 let preview = truncate_preview("short", 500);
529
530 assert_eq!(preview, "short");
532 }
533
534 #[test]
535 fn test_truncate_preview_truncates_long_responses() {
537 let long_response = "a".repeat(600);
539
540 let preview = truncate_preview(&long_response, 500);
542
543 assert!(preview.starts_with(&"a".repeat(500)));
545 assert!(preview.contains("100 more chars"));
546 }
547}