Skip to main content

ag_protocol/
envelope.rs

1//! Protocol-owned prompt envelopes for agent-facing instruction text.
2
3use crate::{ProtocolRequestProfile, agent_response_json_schema_json};
4
5const PROTOCOL_INSTRUCTIONS_MARKER: &str = "Structured response protocol:";
6const PROTOCOL_REFRESH_REMINDER_MARKER: &str = "Protocol refresh reminder:";
7const REPAIR_RESPONSE_PREVIEW_MAX_CHARS: usize = 500;
8
9const PROTOCOL_INSTRUCTION_PROMPT_TEMPLATE: &str =
10    include_str!("template/protocol_instruction_prompt.md");
11const PROTOCOL_INSTRUCTION_POLICY_PROMPT_TEMPLATE: &str =
12    include_str!("template/protocol_instruction_policy_prompt.md");
13const PROTOCOL_INSTRUCTION_SESSION_TURN_USAGE_TEMPLATE: &str =
14    include_str!("template/protocol_instruction_session_turn_usage.md");
15const PROTOCOL_INSTRUCTION_UTILITY_PROMPT_USAGE_TEMPLATE: &str =
16    include_str!("template/protocol_instruction_utility_prompt_usage.md");
17const PROTOCOL_REFRESH_PROMPT_TEMPLATE: &str = include_str!("template/protocol_refresh_prompt.md");
18const PROTOCOL_REFRESH_SESSION_TURN_INSTRUCTION_TEMPLATE: &str =
19    include_str!("template/protocol_refresh_session_turn_instruction.md");
20const PROTOCOL_REFRESH_UTILITY_PROMPT_INSTRUCTION_TEMPLATE: &str =
21    include_str!("template/protocol_refresh_utility_prompt_instruction.md");
22const PROTOCOL_REPAIR_PROMPT_TEMPLATE: &str = include_str!("template/protocol_repair_prompt.md");
23
24/// Controls whether bootstrap prompt instructions include the full protocol
25/// JSON Schema text.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ProtocolSchemaInstructionMode {
28    /// Include the full self-descriptive JSON Schema in the prompt because
29    /// the provider does not enforce Agentty's response schema natively.
30    PromptSchema,
31    /// Omit the full schema text because the provider enforces the same
32    /// response schema through its transport-level structured output API.
33    TransportSchema,
34}
35
36impl ProtocolSchemaInstructionMode {
37    /// Returns whether bootstrap instructions should embed the full JSON
38    /// Schema text in the prompt body.
39    fn includes_response_json_schema(self) -> bool {
40        matches!(self, Self::PromptSchema)
41    }
42}
43
44/// Prepends structured response protocol instructions to a prompt.
45///
46/// Tells agents to emit one top-level JSON object that matches Agentty's
47/// structured protocol while selecting the cheapest safe schema guidance for
48/// the current provider. Providers without native structured output receive
49/// the full JSON Schema in the prompt; providers with native enforcement get
50/// policy and field-routing instructions only. If the prompt already contains
51/// the protocol marker, this function returns the prompt unchanged to avoid
52/// duplicated guidance.
53#[must_use]
54pub fn prepend_protocol_instructions(
55    prompt: &str,
56    profile: ProtocolRequestProfile,
57    schema_instruction_mode: ProtocolSchemaInstructionMode,
58) -> String {
59    if prompt.contains(PROTOCOL_INSTRUCTIONS_MARKER) {
60        return prompt.to_string();
61    }
62
63    let protocol_usage_instructions = render_protocol_usage_instructions(profile);
64    if !schema_instruction_mode.includes_response_json_schema() {
65        return render_template(
66            PROTOCOL_INSTRUCTION_POLICY_PROMPT_TEMPLATE,
67            &[
68                (
69                    "{{ protocol_usage_instructions }}",
70                    &protocol_usage_instructions,
71                ),
72                ("{{ prompt }}", prompt),
73            ],
74        );
75    }
76
77    let response_json_schema = agent_response_json_schema_json();
78
79    render_template(
80        PROTOCOL_INSTRUCTION_PROMPT_TEMPLATE,
81        &[
82            (
83                "{{ protocol_usage_instructions }}",
84                &protocol_usage_instructions,
85            ),
86            ("{{ response_json_schema }}", &response_json_schema),
87            ("{{ prompt }}", prompt),
88        ],
89    )
90}
91
92/// Prepends a compact refresh reminder for providers that already received
93/// the full instruction contract in the active context.
94#[must_use]
95pub fn prepend_protocol_refresh_reminder(prompt: &str, profile: ProtocolRequestProfile) -> String {
96    if prompt.contains(PROTOCOL_INSTRUCTIONS_MARKER)
97        || prompt.contains(PROTOCOL_REFRESH_REMINDER_MARKER)
98    {
99        return prompt.to_string();
100    }
101
102    let protocol_refresh_instructions = render_protocol_refresh_instructions(profile);
103
104    render_template(
105        PROTOCOL_REFRESH_PROMPT_TEMPLATE,
106        &[
107            (
108                "{{ protocol_refresh_instructions }}",
109                &protocol_refresh_instructions,
110            ),
111            ("{{ prompt }}", prompt),
112        ],
113    )
114}
115
116/// Builds the protocol repair prompt text for one failed parse attempt.
117///
118/// The returned prompt is self-contained: it includes the full JSON schema
119/// and the `Structured response protocol:` marker so it can be submitted
120/// through the standard prompt pipeline without being double-wrapped.
121#[must_use]
122pub fn build_protocol_repair_prompt(parse_error: &str, malformed_response: &str) -> String {
123    let response_json_schema = agent_response_json_schema_json();
124    let response_preview = truncate_preview(malformed_response, REPAIR_RESPONSE_PREVIEW_MAX_CHARS);
125
126    render_template(
127        PROTOCOL_REPAIR_PROMPT_TEMPLATE,
128        &[
129            ("{{ parse_error }}", parse_error),
130            ("{{ response_json_schema }}", &response_json_schema),
131            ("{{ response_preview }}", &response_preview),
132        ],
133    )
134}
135
136fn render_protocol_usage_instructions(profile: ProtocolRequestProfile) -> String {
137    if matches!(profile, ProtocolRequestProfile::SessionTurn) {
138        return trim_template(PROTOCOL_INSTRUCTION_SESSION_TURN_USAGE_TEMPLATE);
139    }
140
141    trim_template(PROTOCOL_INSTRUCTION_UTILITY_PROMPT_USAGE_TEMPLATE)
142}
143
144fn render_protocol_refresh_instructions(profile: ProtocolRequestProfile) -> String {
145    if matches!(profile, ProtocolRequestProfile::SessionTurn) {
146        return trim_template(PROTOCOL_REFRESH_SESSION_TURN_INSTRUCTION_TEMPLATE);
147    }
148
149    trim_template(PROTOCOL_REFRESH_UTILITY_PROMPT_INSTRUCTION_TEMPLATE)
150}
151
152fn render_template(template: &str, replacements: &[(&str, &str)]) -> String {
153    let mut rendered = template.to_string();
154    for (placeholder, value) in replacements {
155        rendered = rendered.replace(placeholder, value);
156    }
157
158    trim_template(&rendered)
159}
160
161fn trim_template(template: &str) -> String {
162    template.trim_end().to_string()
163}
164
165fn truncate_preview(raw: &str, max_chars: usize) -> String {
166    let preview: String = raw.chars().take(max_chars).collect();
167    let total_chars = raw.chars().count();
168
169    if total_chars <= max_chars {
170        return preview;
171    }
172
173    format!("{preview}\n... [{} more chars]", total_chars - max_chars)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    /// Ensures session prompts include the critical protocol contract markers.
182    fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
183        // Arrange
184        let prompt = "Implement feature";
185
186        // Act
187        let rendered_prompt = prepend_protocol_instructions(
188            prompt,
189            ProtocolRequestProfile::SessionTurn,
190            ProtocolSchemaInstructionMode::PromptSchema,
191        );
192
193        // Assert
194        assert!(rendered_prompt.contains("File path output requirements:"));
195        assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
196        assert!(
197            rendered_prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`.")
198        );
199        assert!(rendered_prompt.contains("If you run git commands, use read-only commands only"));
200        assert!(rendered_prompt.contains("Do not run mutating git commands"));
201        assert!(rendered_prompt.contains("Quality check requirements:"));
202        assert!(rendered_prompt.contains("repository-defined quality checks"));
203        let normalized_rendered_prompt = rendered_prompt.split_whitespace().collect::<Vec<_>>();
204        let normalized_rendered_prompt = normalized_rendered_prompt.join(" ");
205        assert!(normalized_rendered_prompt.contains("affected dependencies and dependents"));
206        assert!(rendered_prompt.contains("full repository test/check suite"));
207        assert!(rendered_prompt.contains("Remove any temporary scripts or files"));
208        assert!(rendered_prompt.contains("Structured response protocol:"));
209        assert!(rendered_prompt.contains("Return a single JSON object"));
210        assert!(rendered_prompt.contains("Do not wrap the JSON in markdown code fences."));
211        assert!(rendered_prompt.contains("Follow this JSON Schema exactly."));
212        assert!(rendered_prompt.contains("Treat the JSON Schema titles and descriptions"));
213        assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
214        assert!(
215            rendered_prompt
216                .contains("______________________________________________________________________")
217        );
218        assert!(!rendered_prompt.contains("{# task separator #}"));
219        assert!(rendered_prompt.contains("For this session turn"));
220        assert!(normalized_rendered_prompt.contains("Do not create commits"));
221        assert!(normalized_rendered_prompt.contains("suggest creating commits"));
222        assert!(rendered_prompt.contains("summary"));
223        assert!(rendered_prompt.contains("turn"));
224        assert!(rendered_prompt.contains("session"));
225        assert!(rendered_prompt.contains("\"answer\""));
226        assert!(rendered_prompt.contains("\"questions\""));
227        assert!(rendered_prompt.contains("\"title\""));
228        assert!(rendered_prompt.contains("\"description\""));
229        assert!(rendered_prompt.contains("summary"));
230        assert!(rendered_prompt.ends_with(prompt));
231    }
232
233    #[test]
234    /// Ensures schema-enforcing transports get protocol policy without the
235    /// large prompt-side JSON Schema body.
236    fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
237        // Arrange
238        let prompt = "Implement feature";
239
240        // Act
241        let rendered_prompt = prepend_protocol_instructions(
242            prompt,
243            ProtocolRequestProfile::SessionTurn,
244            ProtocolSchemaInstructionMode::TransportSchema,
245        );
246
247        // Assert
248        assert!(rendered_prompt.contains("Structured response protocol:"));
249        assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
250        assert!(rendered_prompt.contains("Return a single JSON object"));
251        assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
252        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
253        assert!(rendered_prompt.ends_with(prompt));
254    }
255
256    #[test]
257    /// Ensures protocol instructions are not duplicated when already present.
258    fn test_prepend_protocol_instructions_is_idempotent() {
259        // Arrange
260        let prompt = prepend_protocol_instructions(
261            "Implement feature",
262            ProtocolRequestProfile::SessionTurn,
263            ProtocolSchemaInstructionMode::PromptSchema,
264        );
265
266        // Act
267        let rendered_prompt = prepend_protocol_instructions(
268            &prompt,
269            ProtocolRequestProfile::UtilityPrompt,
270            ProtocolSchemaInstructionMode::TransportSchema,
271        );
272
273        // Assert
274        assert_eq!(rendered_prompt, prompt);
275    }
276
277    #[test]
278    /// Ensures one-shot prompts reuse the shared full-schema protocol
279    /// instructions.
280    fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
281        // Arrange
282        let prompt = "Generate title";
283
284        // Act
285        let rendered_prompt = prepend_protocol_instructions(
286            prompt,
287            ProtocolRequestProfile::UtilityPrompt,
288            ProtocolSchemaInstructionMode::PromptSchema,
289        );
290
291        // Assert
292        assert!(rendered_prompt.contains("Structured response protocol:"));
293        assert!(
294            rendered_prompt
295                .contains("______________________________________________________________________")
296        );
297        assert!(rendered_prompt.contains("For this one-shot utility prompt"));
298        assert!(rendered_prompt.contains(r#"{"answer":"...","questions":[],"summary":null}"#));
299        assert!(rendered_prompt.contains("\"summary\""));
300        assert!(rendered_prompt.ends_with(prompt));
301    }
302
303    #[test]
304    /// Ensures user prompt text is inserted after generated protocol
305    /// placeholders so prompt content cannot trigger recursive expansion.
306    fn test_prepend_protocol_instructions_preserves_prompt_placeholders() {
307        // Arrange
308        let prompt =
309            "Keep these literal: {{ response_json_schema }} {{ protocol_usage_instructions }}";
310
311        // Act
312        let rendered_prompt = prepend_protocol_instructions(
313            prompt,
314            ProtocolRequestProfile::UtilityPrompt,
315            ProtocolSchemaInstructionMode::PromptSchema,
316        );
317
318        // Assert
319        assert!(rendered_prompt.ends_with(prompt));
320    }
321
322    #[test]
323    /// Ensures compact refresh reminders omit the full schema while keeping
324    /// the contract reminder and task body.
325    fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
326        // Arrange
327        let prompt = "Continue the implementation";
328
329        // Act
330        let rendered_prompt =
331            prepend_protocol_refresh_reminder(prompt, ProtocolRequestProfile::SessionTurn);
332
333        // Assert
334        assert!(rendered_prompt.contains("Protocol refresh reminder:"));
335        assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
336        assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
337        assert!(rendered_prompt.contains("Do not run mutating git commands."));
338        assert!(
339            rendered_prompt
340                .contains("______________________________________________________________________")
341        );
342        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
343        assert!(rendered_prompt.ends_with(prompt));
344    }
345
346    #[test]
347    /// Ensures refresh prompt text is inserted after generated reminder
348    /// placeholders so prompt content cannot trigger recursive expansion.
349    fn test_prepend_protocol_refresh_reminder_preserves_prompt_placeholders() {
350        // Arrange
351        let prompt = "Keep this literal: {{ protocol_refresh_instructions }}";
352
353        // Act
354        let rendered_prompt =
355            prepend_protocol_refresh_reminder(prompt, ProtocolRequestProfile::SessionTurn);
356
357        // Assert
358        assert!(rendered_prompt.ends_with(prompt));
359    }
360
361    #[test]
362    /// Repair prompt renders with the parse error and a response preview.
363    fn test_build_protocol_repair_prompt_includes_error_and_preview() {
364        // Arrange
365        let parse_error = "response is not valid protocol JSON: invalid JSON";
366        let malformed_response = "plain text response";
367
368        // Act
369        let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
370
371        // Assert
372        assert!(repair_prompt.contains(parse_error));
373        assert!(repair_prompt.contains("plain text response"));
374        assert!(repair_prompt.contains("Structured response protocol:"));
375        assert!(repair_prompt.contains("Authoritative JSON Schema:"));
376        assert!(repair_prompt.contains("\"answer\""));
377    }
378
379    #[test]
380    /// Ensures malformed response previews are inserted after generated
381    /// schema placeholders so agent output cannot trigger recursive expansion.
382    fn test_build_protocol_repair_prompt_preserves_response_preview_placeholders() {
383        // Arrange
384        let malformed_response = "Keep this literal: {{ response_json_schema }}";
385
386        // Act
387        let repair_prompt =
388            build_protocol_repair_prompt("schema validation failed", malformed_response);
389
390        // Assert
391        assert!(repair_prompt.contains(malformed_response));
392    }
393
394    #[test]
395    /// Repair prompt truncates long malformed responses to the preview limit.
396    fn test_build_protocol_repair_prompt_truncates_long_response() {
397        // Arrange
398        let parse_error = "schema validation failed";
399        let malformed_response = "x".repeat(1000);
400
401        // Act
402        let repair_prompt = build_protocol_repair_prompt(parse_error, &malformed_response);
403
404        // Assert
405        assert!(repair_prompt.contains("500 more chars"));
406        assert!(!repair_prompt.contains(&malformed_response));
407    }
408
409    #[test]
410    /// Repair prompt includes the protocol marker to prevent double-wrapping.
411    fn test_build_protocol_repair_prompt_contains_protocol_marker() {
412        // Arrange / Act
413        let repair_prompt = build_protocol_repair_prompt("error", "response");
414
415        // Assert
416        assert!(repair_prompt.contains("Structured response protocol:"));
417    }
418
419    #[test]
420    /// Short responses are not truncated.
421    fn test_truncate_preview_keeps_short_responses_intact() {
422        // Arrange / Act
423        let preview = truncate_preview("short", 500);
424
425        // Assert
426        assert_eq!(preview, "short");
427    }
428
429    #[test]
430    /// Long responses are truncated with a character count suffix.
431    fn test_truncate_preview_truncates_long_responses() {
432        // Arrange
433        let long_response = "a".repeat(600);
434
435        // Act
436        let preview = truncate_preview(&long_response, 500);
437
438        // Assert
439        assert!(preview.starts_with(&"a".repeat(500)));
440        assert!(preview.contains("100 more chars"));
441    }
442}