1use std::path::Path;
4
5use askama::Template;
6
7use crate::{ProtocolRequestProfile, agent_response_json_schema_json};
8
9const PROTOCOL_INSTRUCTIONS_MARKER: &str = "Structured response protocol:";
10const PROTOCOL_REFRESH_REMINDER_MARKER: &str = "Protocol refresh reminder:";
11const REPAIR_RESPONSE_PREVIEW_MAX_CHARS: usize = 500;
12
13#[derive(Template)]
15#[template(path = "protocol_instruction_prompt.md", escape = "none")]
16struct ProtocolInstructionPromptTemplate<'a> {
17 prompt: &'a str,
18 protocol_usage_instructions: &'a str,
19 response_json_schema: &'a str,
20 workspace_root: &'a str,
21}
22
23#[derive(Template)]
26#[template(path = "protocol_instruction_policy_prompt.md", escape = "none")]
27struct ProtocolInstructionPolicyPromptTemplate<'a> {
28 prompt: &'a str,
29 protocol_usage_instructions: &'a str,
30 workspace_root: &'a str,
31}
32
33#[derive(Template)]
35#[template(path = "protocol_instruction_session_turn_usage.md", escape = "none")]
36struct ProtocolInstructionSessionTurnUsageTemplate;
37
38#[derive(Template)]
40#[template(path = "protocol_instruction_utility_prompt_usage.md", escape = "none")]
41struct ProtocolInstructionUtilityPromptUsageTemplate;
42
43#[derive(Template)]
45#[template(path = "protocol_refresh_prompt.md", escape = "none")]
46struct ProtocolRefreshPromptTemplate<'a> {
47 prompt: &'a str,
48 protocol_refresh_instructions: &'a str,
49 workspace_root: &'a str,
50}
51
52#[derive(Template)]
54#[template(path = "protocol_refresh_session_turn_instruction.md", escape = "none")]
55struct ProtocolRefreshSessionTurnInstructionTemplate;
56
57#[derive(Template)]
59#[template(
60 path = "protocol_refresh_utility_prompt_instruction.md",
61 escape = "none"
62)]
63struct ProtocolRefreshUtilityPromptInstructionTemplate;
64
65#[derive(Template)]
67#[template(path = "protocol_repair_prompt.md", escape = "none")]
68struct ProtocolRepairPromptTemplate<'a> {
69 parse_error: &'a str,
70 response_json_schema: &'a str,
71 response_preview: &'a str,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ProtocolSchemaInstructionMode {
78 PromptSchema,
81 TransportSchema,
84}
85
86impl ProtocolSchemaInstructionMode {
87 fn includes_response_json_schema(self) -> bool {
90 matches!(self, Self::PromptSchema)
91 }
92}
93
94#[must_use]
105pub fn prepend_protocol_instructions(
106 prompt: &str,
107 profile: ProtocolRequestProfile,
108 schema_instruction_mode: ProtocolSchemaInstructionMode,
109 workspace_root: &Path,
110) -> String {
111 if prompt.contains(PROTOCOL_INSTRUCTIONS_MARKER) {
112 return prompt.to_string();
113 }
114
115 let protocol_usage_instructions = render_protocol_usage_instructions(profile);
116 let workspace_root = workspace_root.display().to_string();
117 if !schema_instruction_mode.includes_response_json_schema() {
118 let template = ProtocolInstructionPolicyPromptTemplate {
119 prompt,
120 protocol_usage_instructions: &protocol_usage_instructions,
121 workspace_root: &workspace_root,
122 };
123
124 return render_template("protocol_instruction_policy_prompt.md", &template);
125 }
126
127 let response_json_schema = agent_response_json_schema_json();
128 let template = ProtocolInstructionPromptTemplate {
129 prompt,
130 protocol_usage_instructions: &protocol_usage_instructions,
131 response_json_schema: &response_json_schema,
132 workspace_root: &workspace_root,
133 };
134
135 render_template("protocol_instruction_prompt.md", &template)
136}
137
138#[must_use]
145pub fn prepend_protocol_refresh_reminder(
146 prompt: &str,
147 profile: ProtocolRequestProfile,
148 workspace_root: &Path,
149) -> String {
150 if prompt.contains(PROTOCOL_INSTRUCTIONS_MARKER)
151 || prompt.contains(PROTOCOL_REFRESH_REMINDER_MARKER)
152 {
153 return prompt.to_string();
154 }
155
156 let protocol_refresh_instructions = render_protocol_refresh_instructions(profile);
157 let workspace_root = workspace_root.display().to_string();
158 let template = ProtocolRefreshPromptTemplate {
159 prompt,
160 protocol_refresh_instructions: &protocol_refresh_instructions,
161 workspace_root: &workspace_root,
162 };
163
164 render_template("protocol_refresh_prompt.md", &template)
165}
166
167#[must_use]
173pub fn build_protocol_repair_prompt(parse_error: &str, malformed_response: &str) -> String {
174 let response_json_schema = agent_response_json_schema_json();
175 let response_preview = truncate_preview(malformed_response, REPAIR_RESPONSE_PREVIEW_MAX_CHARS);
176 let template = ProtocolRepairPromptTemplate {
177 parse_error,
178 response_json_schema: &response_json_schema,
179 response_preview: &response_preview,
180 };
181
182 render_template("protocol_repair_prompt.md", &template)
183}
184
185fn render_protocol_usage_instructions(profile: ProtocolRequestProfile) -> String {
186 if matches!(profile, ProtocolRequestProfile::SessionTurn) {
187 return render_template(
188 "protocol_instruction_session_turn_usage.md",
189 &ProtocolInstructionSessionTurnUsageTemplate,
190 );
191 }
192
193 render_template(
194 "protocol_instruction_utility_prompt_usage.md",
195 &ProtocolInstructionUtilityPromptUsageTemplate,
196 )
197}
198
199fn render_protocol_refresh_instructions(profile: ProtocolRequestProfile) -> String {
200 if matches!(profile, ProtocolRequestProfile::SessionTurn) {
201 return render_template(
202 "protocol_refresh_session_turn_instruction.md",
203 &ProtocolRefreshSessionTurnInstructionTemplate,
204 );
205 }
206
207 render_template(
208 "protocol_refresh_utility_prompt_instruction.md",
209 &ProtocolRefreshUtilityPromptInstructionTemplate,
210 )
211}
212
213fn render_template(template_name: &str, template: &impl Template) -> String {
214 let rendered = match template.render() {
215 Ok(rendered) => rendered,
216 Err(error) => format!("Failed to render `{template_name}`: {error}"),
217 };
218
219 trim_template(&rendered)
220}
221
222fn trim_template(template: &str) -> String {
223 template.trim_end().to_string()
224}
225
226fn truncate_preview(raw: &str, max_chars: usize) -> String {
227 let preview: String = raw.chars().take(max_chars).collect();
228 let total_chars = raw.chars().count();
229
230 if total_chars <= max_chars {
231 return preview;
232 }
233
234 format!("{preview}\n... [{} more chars]", total_chars - max_chars)
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 fn test_workspace_root() -> &'static Path {
243 Path::new("/tmp/agentty-wt/session-1")
244 }
245
246 #[test]
247 fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
249 let prompt = "Implement feature";
251
252 let rendered_prompt = prepend_protocol_instructions(
254 prompt,
255 ProtocolRequestProfile::SessionTurn,
256 ProtocolSchemaInstructionMode::PromptSchema,
257 test_workspace_root(),
258 );
259
260 assert!(rendered_prompt.contains("File path output requirements:"));
262 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
263 assert!(rendered_prompt.contains("Your workspace root is `/tmp/agentty-wt/session-1`."));
264 assert!(rendered_prompt.contains("Anything outside that"));
265 assert!(rendered_prompt.contains("root is read-only."));
266 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
267 assert!(
268 rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
269 );
270 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
271 assert!(rendered_prompt.contains("Do not run mutating git commands"));
272 assert!(rendered_prompt.contains("Quality check requirements:"));
273 assert!(rendered_prompt.contains("repository-defined quality checks"));
274 let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
275 let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
276 assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
277 assert!(rendered_prompt.contains("full repository test/check suite"));
278 assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
279 assert!(rendered_prompt.contains("Structured response protocol:"));
280 assert!(rendered_prompt.contains("Return a single JSON object"));
281 assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
282 assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
283 assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
284 assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
285 assert!(
286 rendered_prompt
287 .contains("______________________________________________________________________")
288 );
289 assert!(!rendered_prompt.contains("{# task separator #}"));
290 assert!(rendered_prompt.contains("For this session turn"));
291 assert!(rendered_prompt.contains("```mermaid"));
292 assert!(rendered_prompt.contains("put the diagram only in `answer`"));
293 assert!(normalized_rendered_prompt.contains("start the opening fence at column 1"));
294 assert!(rendered_prompt.contains("Do not emit Mermaid as plain text"));
295 assert!(rendered_prompt.contains("Supported mermaid syntax"));
296 assert!(normalized_rendered_prompt.contains("Do not create commits"));
297 assert!(normalized_rendered_prompt.contains("suggest creating commits"));
298 assert!(rendered_prompt.contains("summary"));
299 assert!(rendered_prompt.contains("turn"));
300 assert!(rendered_prompt.contains("session"));
301 assert!(rendered_prompt.contains("\"answer\""));
302 assert!(rendered_prompt.contains("\"questions\""));
303 assert!(rendered_prompt.contains("\"title\""));
304 assert!(rendered_prompt.contains("\"description\""));
305 assert!(rendered_prompt.contains("summary"));
306 assert!(rendered_prompt.ends_with(prompt));
307 }
308
309 #[test]
310 fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
313 let prompt = "Implement feature";
315
316 let rendered_prompt = prepend_protocol_instructions(
318 prompt,
319 ProtocolRequestProfile::SessionTurn,
320 ProtocolSchemaInstructionMode::TransportSchema,
321 test_workspace_root(),
322 );
323
324 assert!(rendered_prompt.contains("Structured response protocol:"));
326 assert!(rendered_prompt.contains("Workspace isolation requirements:"));
327 assert!(rendered_prompt.contains("Your workspace root is `/tmp/agentty-wt/session-1`."));
328 assert!(rendered_prompt.contains("Anything outside that"));
329 assert!(rendered_prompt.contains("root is read-only."));
330 assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
331 assert!(rendered_prompt.contains("Return a single JSON object"));
332 assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
333 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
334 assert!(rendered_prompt.ends_with(prompt));
335 }
336
337 #[test]
338 fn test_prepend_protocol_instructions_is_idempotent() {
340 let prompt = prepend_protocol_instructions(
342 "Implement feature",
343 ProtocolRequestProfile::SessionTurn,
344 ProtocolSchemaInstructionMode::PromptSchema,
345 test_workspace_root(),
346 );
347
348 let rendered_prompt = prepend_protocol_instructions(
350 &prompt,
351 ProtocolRequestProfile::UtilityPrompt,
352 ProtocolSchemaInstructionMode::TransportSchema,
353 test_workspace_root(),
354 );
355
356 assert_eq!(rendered_prompt, prompt);
358 }
359
360 #[test]
361 fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
364 let prompt = "Generate title";
366
367 let rendered_prompt = prepend_protocol_instructions(
369 prompt,
370 ProtocolRequestProfile::UtilityPrompt,
371 ProtocolSchemaInstructionMode::PromptSchema,
372 test_workspace_root(),
373 );
374
375 assert!(rendered_prompt.contains("Structured response protocol:"));
377 assert!(
378 rendered_prompt
379 .contains("______________________________________________________________________")
380 );
381 assert!(rendered_prompt.contains("For this one-shot utility prompt"));
382 assert!(!rendered_prompt.contains("mermaid"));
383 assert!(rendered_prompt.contains(r#"{"answer":"...","questions":[],"summary":null}"#));
384 assert!(rendered_prompt.contains("\"summary\""));
385 assert!(rendered_prompt.ends_with(prompt));
386 }
387
388 #[test]
389 fn test_prepend_protocol_instructions_preserves_prompt_placeholders() {
392 let prompt = "Keep these literal: {{ response_json_schema }} {{ \
394 protocol_usage_instructions }} {{ workspace_root }}";
395
396 let rendered_prompt = prepend_protocol_instructions(
398 prompt,
399 ProtocolRequestProfile::UtilityPrompt,
400 ProtocolSchemaInstructionMode::PromptSchema,
401 test_workspace_root(),
402 );
403
404 assert!(rendered_prompt.ends_with(prompt));
406 }
407
408 #[test]
409 fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
412 let prompt = "Continue the implementation";
414
415 let rendered_prompt = prepend_protocol_refresh_reminder(
417 prompt,
418 ProtocolRequestProfile::SessionTurn,
419 test_workspace_root(),
420 );
421
422 assert!(rendered_prompt.contains("Protocol refresh reminder:"));
424 assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
425 assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
426 assert!(rendered_prompt.contains("Do not run mutating git commands."));
427 assert!(rendered_prompt.contains("inside the workspace root `/tmp/agentty-wt/session-1`"));
428 assert!(rendered_prompt.contains("anything outside that root is read-only"));
429 assert!(rendered_prompt.contains("Mermaid diagrams must remain in `answer`"));
430 assert!(rendered_prompt.contains("fences without the `mermaid` info string"));
431 assert!(
432 rendered_prompt
433 .contains("______________________________________________________________________")
434 );
435 assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
436 assert!(rendered_prompt.ends_with(prompt));
437 }
438
439 #[test]
440 fn test_prepend_protocol_refresh_reminder_preserves_prompt_placeholders() {
443 let prompt = "Keep this literal: {{ protocol_refresh_instructions }} {{ workspace_root }}";
445
446 let rendered_prompt = prepend_protocol_refresh_reminder(
448 prompt,
449 ProtocolRequestProfile::SessionTurn,
450 test_workspace_root(),
451 );
452
453 assert!(rendered_prompt.ends_with(prompt));
455 }
456
457 #[test]
458 fn test_build_protocol_repair_prompt_includes_error_and_preview() {
460 let parse_error = "response is not valid protocol JSON: invalid JSON";
462 let malformed_response = "plain text response";
463
464 let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
466
467 assert!(repair_prompt.contains(parse_error));
469 assert!(repair_prompt.contains("plain text response"));
470 assert!(repair_prompt.contains("Structured response protocol:"));
471 assert!(repair_prompt.contains("Authoritative JSON Schema:"));
472 assert!(repair_prompt.contains("\"answer\""));
473 }
474
475 #[test]
476 fn test_build_protocol_repair_prompt_preserves_response_preview_placeholders() {
479 let malformed_response = "Keep this literal: {{ response_json_schema }}";
481
482 let repair_prompt =
484 build_protocol_repair_prompt("schema validation failed", malformed_response);
485
486 assert!(repair_prompt.contains(malformed_response));
488 }
489
490 #[test]
491 fn test_build_protocol_repair_prompt_truncates_long_response() {
493 let parse_error = "schema validation failed";
495 let malformed_response = "x".repeat(1000);
496
497 let repair_prompt = build_protocol_repair_prompt(parse_error, &malformed_response);
499
500 assert!(repair_prompt.contains("500 more chars"));
502 assert!(!repair_prompt.contains(&malformed_response));
503 }
504
505 #[test]
506 fn test_build_protocol_repair_prompt_contains_protocol_marker() {
508 let repair_prompt = build_protocol_repair_prompt("error", "response");
510
511 assert!(repair_prompt.contains("Structured response protocol:"));
513 }
514
515 #[test]
516 fn test_truncate_preview_keeps_short_responses_intact() {
518 let preview = truncate_preview("short", 500);
520
521 assert_eq!(preview, "short");
523 }
524
525 #[test]
526 fn test_truncate_preview_truncates_long_responses() {
528 let long_response = "a".repeat(600);
530
531 let preview = truncate_preview(&long_response, 500);
533
534 assert!(preview.starts_with(&"a".repeat(500)));
536 assert!(preview.contains("100 more chars"));
537 }
538}