Skip to main content

ag_protocol/
envelope.rs

1//! Protocol-owned prompt envelopes for agent-facing instruction text.
2
3use 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/// Askama view model for full protocol instructions with prompt-side schema.
14#[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/// Askama view model for protocol instructions when the transport enforces
24/// the response schema.
25#[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/// Askama view model for session-turn protocol usage instructions.
34#[derive(Template)]
35#[template(path = "protocol_instruction_session_turn_usage.md", escape = "none")]
36struct ProtocolInstructionSessionTurnUsageTemplate;
37
38/// Askama view model for one-shot protocol usage instructions.
39#[derive(Template)]
40#[template(path = "protocol_instruction_utility_prompt_usage.md", escape = "none")]
41struct ProtocolInstructionUtilityPromptUsageTemplate;
42
43/// Askama view model for compact refresh reminders.
44#[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/// Askama view model for session-turn refresh instructions.
53#[derive(Template)]
54#[template(path = "protocol_refresh_session_turn_instruction.md", escape = "none")]
55struct ProtocolRefreshSessionTurnInstructionTemplate;
56
57/// Askama view model for one-shot refresh instructions.
58#[derive(Template)]
59#[template(
60    path = "protocol_refresh_utility_prompt_instruction.md",
61    escape = "none"
62)]
63struct ProtocolRefreshUtilityPromptInstructionTemplate;
64
65/// Askama view model for repair prompts after protocol parse failures.
66#[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/// Controls whether bootstrap prompt instructions include the full protocol
75/// JSON Schema text.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ProtocolSchemaInstructionMode {
78    /// Include the full self-descriptive JSON Schema in the prompt because
79    /// the provider does not enforce Agentty's response schema natively.
80    PromptSchema,
81    /// Omit the full schema text because the provider enforces the same
82    /// response schema through its transport-level structured output API.
83    TransportSchema,
84}
85
86impl ProtocolSchemaInstructionMode {
87    /// Returns whether bootstrap instructions should embed the full JSON
88    /// Schema text in the prompt body.
89    fn includes_response_json_schema(self) -> bool {
90        matches!(self, Self::PromptSchema)
91    }
92}
93
94/// Prepends structured response protocol instructions to a prompt.
95///
96/// Tells agents to emit one top-level JSON object that matches Agentty's
97/// structured protocol while selecting the cheapest safe schema guidance for
98/// the current provider. Providers without native structured output receive
99/// the full JSON Schema in the prompt; providers with native enforcement get
100/// policy and field-routing instructions only. `workspace_root` names the
101/// only writable directory for the turn. If the prompt already contains the
102/// protocol marker, this function returns the prompt unchanged to avoid
103/// duplicated guidance.
104#[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/// Prepends a compact refresh reminder for providers that already received
139/// the full instruction contract in the active context.
140///
141/// The reminder repeats the workspace-isolation boundary for
142/// `workspace_root` so long-lived provider contexts keep the rule even after
143/// provider-side context compaction.
144#[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/// Builds the protocol repair prompt text for one failed parse attempt.
168///
169/// The returned prompt is self-contained: it includes the full JSON schema
170/// and the `Structured response protocol:` marker so it can be submitted
171/// through the standard prompt pipeline without being double-wrapped.
172#[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    /// Returns the workspace root used by envelope rendering tests.
242    fn test_workspace_root() -> &'static Path {
243        Path::new("/tmp/agentty-wt/session-1")
244    }
245
246    #[test]
247    /// Ensures session prompts include the critical protocol contract markers.
248    fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
249        // Arrange
250        let prompt = "Implement feature";
251
252        // Act
253        let rendered_prompt = prepend_protocol_instructions(
254            prompt,
255            ProtocolRequestProfile::SessionTurn,
256            ProtocolSchemaInstructionMode::PromptSchema,
257            test_workspace_root(),
258        );
259
260        // Assert
261        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    /// Ensures schema-enforcing transports get protocol policy without the
311    /// large prompt-side JSON Schema body.
312    fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
313        // Arrange
314        let prompt = "Implement feature";
315
316        // Act
317        let rendered_prompt = prepend_protocol_instructions(
318            prompt,
319            ProtocolRequestProfile::SessionTurn,
320            ProtocolSchemaInstructionMode::TransportSchema,
321            test_workspace_root(),
322        );
323
324        // Assert
325        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    /// Ensures protocol instructions are not duplicated when already present.
339    fn test_prepend_protocol_instructions_is_idempotent() {
340        // Arrange
341        let prompt = prepend_protocol_instructions(
342            "Implement feature",
343            ProtocolRequestProfile::SessionTurn,
344            ProtocolSchemaInstructionMode::PromptSchema,
345            test_workspace_root(),
346        );
347
348        // Act
349        let rendered_prompt = prepend_protocol_instructions(
350            &prompt,
351            ProtocolRequestProfile::UtilityPrompt,
352            ProtocolSchemaInstructionMode::TransportSchema,
353            test_workspace_root(),
354        );
355
356        // Assert
357        assert_eq!(rendered_prompt, prompt);
358    }
359
360    #[test]
361    /// Ensures one-shot prompts reuse the shared full-schema protocol
362    /// instructions.
363    fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
364        // Arrange
365        let prompt = "Generate title";
366
367        // Act
368        let rendered_prompt = prepend_protocol_instructions(
369            prompt,
370            ProtocolRequestProfile::UtilityPrompt,
371            ProtocolSchemaInstructionMode::PromptSchema,
372            test_workspace_root(),
373        );
374
375        // Assert
376        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    /// Ensures user prompt text is inserted after generated protocol
390    /// placeholders so prompt content cannot trigger recursive expansion.
391    fn test_prepend_protocol_instructions_preserves_prompt_placeholders() {
392        // Arrange
393        let prompt = "Keep these literal: {{ response_json_schema }} {{ \
394                      protocol_usage_instructions }} {{ workspace_root }}";
395
396        // Act
397        let rendered_prompt = prepend_protocol_instructions(
398            prompt,
399            ProtocolRequestProfile::UtilityPrompt,
400            ProtocolSchemaInstructionMode::PromptSchema,
401            test_workspace_root(),
402        );
403
404        // Assert
405        assert!(rendered_prompt.ends_with(prompt));
406    }
407
408    #[test]
409    /// Ensures compact refresh reminders omit the full schema while keeping
410    /// the contract reminder and task body.
411    fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
412        // Arrange
413        let prompt = "Continue the implementation";
414
415        // Act
416        let rendered_prompt = prepend_protocol_refresh_reminder(
417            prompt,
418            ProtocolRequestProfile::SessionTurn,
419            test_workspace_root(),
420        );
421
422        // Assert
423        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    /// Ensures refresh prompt text is inserted after generated reminder
441    /// placeholders so prompt content cannot trigger recursive expansion.
442    fn test_prepend_protocol_refresh_reminder_preserves_prompt_placeholders() {
443        // Arrange
444        let prompt = "Keep this literal: {{ protocol_refresh_instructions }} {{ workspace_root }}";
445
446        // Act
447        let rendered_prompt = prepend_protocol_refresh_reminder(
448            prompt,
449            ProtocolRequestProfile::SessionTurn,
450            test_workspace_root(),
451        );
452
453        // Assert
454        assert!(rendered_prompt.ends_with(prompt));
455    }
456
457    #[test]
458    /// Repair prompt renders with the parse error and a response preview.
459    fn test_build_protocol_repair_prompt_includes_error_and_preview() {
460        // Arrange
461        let parse_error = "response is not valid protocol JSON: invalid JSON";
462        let malformed_response = "plain text response";
463
464        // Act
465        let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
466
467        // Assert
468        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    /// Ensures malformed response previews are inserted after generated
477    /// schema placeholders so agent output cannot trigger recursive expansion.
478    fn test_build_protocol_repair_prompt_preserves_response_preview_placeholders() {
479        // Arrange
480        let malformed_response = "Keep this literal: {{ response_json_schema }}";
481
482        // Act
483        let repair_prompt =
484            build_protocol_repair_prompt("schema validation failed", malformed_response);
485
486        // Assert
487        assert!(repair_prompt.contains(malformed_response));
488    }
489
490    #[test]
491    /// Repair prompt truncates long malformed responses to the preview limit.
492    fn test_build_protocol_repair_prompt_truncates_long_response() {
493        // Arrange
494        let parse_error = "schema validation failed";
495        let malformed_response = "x".repeat(1000);
496
497        // Act
498        let repair_prompt = build_protocol_repair_prompt(parse_error, &malformed_response);
499
500        // Assert
501        assert!(repair_prompt.contains("500 more chars"));
502        assert!(!repair_prompt.contains(&malformed_response));
503    }
504
505    #[test]
506    /// Repair prompt includes the protocol marker to prevent double-wrapping.
507    fn test_build_protocol_repair_prompt_contains_protocol_marker() {
508        // Arrange / Act
509        let repair_prompt = build_protocol_repair_prompt("error", "response");
510
511        // Assert
512        assert!(repair_prompt.contains("Structured response protocol:"));
513    }
514
515    #[test]
516    /// Short responses are not truncated.
517    fn test_truncate_preview_keeps_short_responses_intact() {
518        // Arrange / Act
519        let preview = truncate_preview("short", 500);
520
521        // Assert
522        assert_eq!(preview, "short");
523    }
524
525    #[test]
526    /// Long responses are truncated with a character count suffix.
527    fn test_truncate_preview_truncates_long_responses() {
528        // Arrange
529        let long_response = "a".repeat(600);
530
531        // Act
532        let preview = truncate_preview(&long_response, 500);
533
534        // Assert
535        assert!(preview.starts_with(&"a".repeat(500)));
536        assert!(preview.contains("100 more chars"));
537    }
538}