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("Supported mermaid syntax"));
293        assert!(normalized_rendered_prompt.contains("Do not create commits"));
294        assert!(normalized_rendered_prompt.contains("suggest creating commits"));
295        assert!(rendered_prompt.contains("summary"));
296        assert!(rendered_prompt.contains("turn"));
297        assert!(rendered_prompt.contains("session"));
298        assert!(rendered_prompt.contains("\"answer\""));
299        assert!(rendered_prompt.contains("\"questions\""));
300        assert!(rendered_prompt.contains("\"title\""));
301        assert!(rendered_prompt.contains("\"description\""));
302        assert!(rendered_prompt.contains("summary"));
303        assert!(rendered_prompt.ends_with(prompt));
304    }
305
306    #[test]
307    /// Ensures schema-enforcing transports get protocol policy without the
308    /// large prompt-side JSON Schema body.
309    fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
310        // Arrange
311        let prompt = "Implement feature";
312
313        // Act
314        let rendered_prompt = prepend_protocol_instructions(
315            prompt,
316            ProtocolRequestProfile::SessionTurn,
317            ProtocolSchemaInstructionMode::TransportSchema,
318            test_workspace_root(),
319        );
320
321        // Assert
322        assert!(rendered_prompt.contains("Structured response protocol:"));
323        assert!(rendered_prompt.contains("Workspace isolation requirements:"));
324        assert!(rendered_prompt.contains("Your workspace root is `/tmp/agentty-wt/session-1`."));
325        assert!(rendered_prompt.contains("Anything outside that"));
326        assert!(rendered_prompt.contains("root is read-only."));
327        assert!(rendered_prompt.contains("provider enforces Agentty's response JSON schema"));
328        assert!(rendered_prompt.contains("Return a single JSON object"));
329        assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
330        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
331        assert!(rendered_prompt.ends_with(prompt));
332    }
333
334    #[test]
335    /// Ensures protocol instructions are not duplicated when already present.
336    fn test_prepend_protocol_instructions_is_idempotent() {
337        // Arrange
338        let prompt = prepend_protocol_instructions(
339            "Implement feature",
340            ProtocolRequestProfile::SessionTurn,
341            ProtocolSchemaInstructionMode::PromptSchema,
342            test_workspace_root(),
343        );
344
345        // Act
346        let rendered_prompt = prepend_protocol_instructions(
347            &prompt,
348            ProtocolRequestProfile::UtilityPrompt,
349            ProtocolSchemaInstructionMode::TransportSchema,
350            test_workspace_root(),
351        );
352
353        // Assert
354        assert_eq!(rendered_prompt, prompt);
355    }
356
357    #[test]
358    /// Ensures one-shot prompts reuse the shared full-schema protocol
359    /// instructions.
360    fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
361        // Arrange
362        let prompt = "Generate title";
363
364        // Act
365        let rendered_prompt = prepend_protocol_instructions(
366            prompt,
367            ProtocolRequestProfile::UtilityPrompt,
368            ProtocolSchemaInstructionMode::PromptSchema,
369            test_workspace_root(),
370        );
371
372        // Assert
373        assert!(rendered_prompt.contains("Structured response protocol:"));
374        assert!(
375            rendered_prompt
376                .contains("______________________________________________________________________")
377        );
378        assert!(rendered_prompt.contains("For this one-shot utility prompt"));
379        assert!(!rendered_prompt.contains("mermaid"));
380        assert!(rendered_prompt.contains(r#"{"answer":"...","questions":[],"summary":null}"#));
381        assert!(rendered_prompt.contains("\"summary\""));
382        assert!(rendered_prompt.ends_with(prompt));
383    }
384
385    #[test]
386    /// Ensures user prompt text is inserted after generated protocol
387    /// placeholders so prompt content cannot trigger recursive expansion.
388    fn test_prepend_protocol_instructions_preserves_prompt_placeholders() {
389        // Arrange
390        let prompt = "Keep these literal: {{ response_json_schema }} {{ \
391                      protocol_usage_instructions }} {{ workspace_root }}";
392
393        // Act
394        let rendered_prompt = prepend_protocol_instructions(
395            prompt,
396            ProtocolRequestProfile::UtilityPrompt,
397            ProtocolSchemaInstructionMode::PromptSchema,
398            test_workspace_root(),
399        );
400
401        // Assert
402        assert!(rendered_prompt.ends_with(prompt));
403    }
404
405    #[test]
406    /// Ensures compact refresh reminders omit the full schema while keeping
407    /// the contract reminder and task body.
408    fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
409        // Arrange
410        let prompt = "Continue the implementation";
411
412        // Act
413        let rendered_prompt = prepend_protocol_refresh_reminder(
414            prompt,
415            ProtocolRequestProfile::SessionTurn,
416            test_workspace_root(),
417        );
418
419        // Assert
420        assert!(rendered_prompt.contains("Protocol refresh reminder:"));
421        assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
422        assert!(rendered_prompt.contains("If you run git commands, use read-only commands only."));
423        assert!(rendered_prompt.contains("Do not run mutating git commands."));
424        assert!(rendered_prompt.contains("inside the workspace root `/tmp/agentty-wt/session-1`"));
425        assert!(rendered_prompt.contains("anything outside that root is read-only"));
426        assert!(
427            rendered_prompt
428                .contains("______________________________________________________________________")
429        );
430        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
431        assert!(rendered_prompt.ends_with(prompt));
432    }
433
434    #[test]
435    /// Ensures refresh prompt text is inserted after generated reminder
436    /// placeholders so prompt content cannot trigger recursive expansion.
437    fn test_prepend_protocol_refresh_reminder_preserves_prompt_placeholders() {
438        // Arrange
439        let prompt = "Keep this literal: {{ protocol_refresh_instructions }} {{ workspace_root }}";
440
441        // Act
442        let rendered_prompt = prepend_protocol_refresh_reminder(
443            prompt,
444            ProtocolRequestProfile::SessionTurn,
445            test_workspace_root(),
446        );
447
448        // Assert
449        assert!(rendered_prompt.ends_with(prompt));
450    }
451
452    #[test]
453    /// Repair prompt renders with the parse error and a response preview.
454    fn test_build_protocol_repair_prompt_includes_error_and_preview() {
455        // Arrange
456        let parse_error = "response is not valid protocol JSON: invalid JSON";
457        let malformed_response = "plain text response";
458
459        // Act
460        let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
461
462        // Assert
463        assert!(repair_prompt.contains(parse_error));
464        assert!(repair_prompt.contains("plain text response"));
465        assert!(repair_prompt.contains("Structured response protocol:"));
466        assert!(repair_prompt.contains("Authoritative JSON Schema:"));
467        assert!(repair_prompt.contains("\"answer\""));
468    }
469
470    #[test]
471    /// Ensures malformed response previews are inserted after generated
472    /// schema placeholders so agent output cannot trigger recursive expansion.
473    fn test_build_protocol_repair_prompt_preserves_response_preview_placeholders() {
474        // Arrange
475        let malformed_response = "Keep this literal: {{ response_json_schema }}";
476
477        // Act
478        let repair_prompt =
479            build_protocol_repair_prompt("schema validation failed", malformed_response);
480
481        // Assert
482        assert!(repair_prompt.contains(malformed_response));
483    }
484
485    #[test]
486    /// Repair prompt truncates long malformed responses to the preview limit.
487    fn test_build_protocol_repair_prompt_truncates_long_response() {
488        // Arrange
489        let parse_error = "schema validation failed";
490        let malformed_response = "x".repeat(1000);
491
492        // Act
493        let repair_prompt = build_protocol_repair_prompt(parse_error, &malformed_response);
494
495        // Assert
496        assert!(repair_prompt.contains("500 more chars"));
497        assert!(!repair_prompt.contains(&malformed_response));
498    }
499
500    #[test]
501    /// Repair prompt includes the protocol marker to prevent double-wrapping.
502    fn test_build_protocol_repair_prompt_contains_protocol_marker() {
503        // Arrange / Act
504        let repair_prompt = build_protocol_repair_prompt("error", "response");
505
506        // Assert
507        assert!(repair_prompt.contains("Structured response protocol:"));
508    }
509
510    #[test]
511    /// Short responses are not truncated.
512    fn test_truncate_preview_keeps_short_responses_intact() {
513        // Arrange / Act
514        let preview = truncate_preview("short", 500);
515
516        // Assert
517        assert_eq!(preview, "short");
518    }
519
520    #[test]
521    /// Long responses are truncated with a character count suffix.
522    fn test_truncate_preview_truncates_long_responses() {
523        // Arrange
524        let long_response = "a".repeat(600);
525
526        // Act
527        let preview = truncate_preview(&long_response, 500);
528
529        // Assert
530        assert!(preview.starts_with(&"a".repeat(500)));
531        assert!(preview.contains("100 more chars"));
532    }
533}