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 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/// Controls whether bootstrap prompt instructions include the full protocol
15/// JSON Schema text.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ProtocolSchemaInstructionMode {
18    /// Include the full self-descriptive JSON Schema in the prompt because
19    /// the provider does not enforce Agentty's response schema natively.
20    PromptSchema,
21    /// Omit the full schema text because the provider enforces the same
22    /// response schema through its transport-level structured output API.
23    TransportSchema,
24}
25
26impl ProtocolSchemaInstructionMode {
27    /// Returns whether bootstrap instructions should embed the full JSON
28    /// Schema text in the prompt body.
29    fn includes_response_json_schema(self) -> bool {
30        matches!(self, Self::PromptSchema)
31    }
32}
33
34/// Prepends structured response protocol instructions to a prompt.
35///
36/// Tells agents to emit one top-level JSON object that matches Agentty's
37/// structured protocol while selecting the cheapest safe schema guidance for
38/// the current provider. Providers without native structured output receive
39/// the full JSON Schema in the prompt; providers with native enforcement get
40/// policy and field-routing instructions only. `workspace_root` names the
41/// only writable directory for the turn. If the prompt already contains the
42/// protocol marker, this function returns the prompt unchanged to avoid
43/// duplicated guidance.
44#[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/// Prepends a compact refresh reminder for providers that already received
79/// the full instruction contract in the active context.
80///
81/// The reminder repeats the workspace-isolation boundary for
82/// `workspace_root` so long-lived provider contexts keep the rule even after
83/// provider-side context compaction.
84#[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/// Builds the protocol repair prompt text for one failed parse attempt.
108///
109/// The returned prompt is self-contained: it includes the full JSON schema
110/// and the `Structured response protocol:` marker so it can be submitted
111/// through the standard prompt pipeline without being double-wrapped.
112#[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/// Askama view model for protocol instructions when the transport enforces
126/// the response schema.
127#[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/// Askama view model for full protocol instructions with prompt-side schema.
136#[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/// Askama view model for compact refresh reminders.
146#[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/// Askama view model for repair prompts after protocol parse failures.
155#[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/// Askama view model for session-turn protocol usage instructions.
164#[derive(Template)]
165#[template(path = "protocol_instruction_session_turn_usage.md", escape = "none")]
166struct ProtocolInstructionSessionTurnUsageTemplate;
167
168/// Askama view model for one-shot protocol usage instructions.
169#[derive(Template)]
170#[template(path = "protocol_instruction_utility_prompt_usage.md", escape = "none")]
171struct ProtocolInstructionUtilityPromptUsageTemplate;
172
173/// Askama view model for session-turn refresh instructions.
174#[derive(Template)]
175#[template(path = "protocol_refresh_session_turn_instruction.md", escape = "none")]
176struct ProtocolRefreshSessionTurnInstructionTemplate;
177
178/// Askama view model for one-shot refresh instructions.
179#[derive(Template)]
180#[template(
181    path = "protocol_refresh_utility_prompt_instruction.md",
182    escape = "none"
183)]
184struct ProtocolRefreshUtilityPromptInstructionTemplate;
185
186/// Renders the protocol usage instructions for one request profile.
187fn 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
201/// Renders the compact protocol refresh instructions for one request profile.
202fn 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
216/// Renders one Askama template and removes trailing whitespace.
217fn 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
226/// Truncates one malformed response preview to a character-count limit.
227fn 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    /// Returns the workspace root used by envelope rendering tests.
243    fn test_workspace_root() -> &'static Path {
244        Path::new("/tmp/agentty-wt/session-1")
245    }
246
247    /// Collapses rendered prompt whitespace for semantic assertions.
248    fn normalize_prompt(prompt: &str) -> String {
249        prompt.split_whitespace().collect::<Vec<_>>().join(" ")
250    }
251
252    #[test]
253    /// Ensures session prompts include the critical protocol contract markers.
254    fn test_prepend_protocol_instructions_adds_session_protocol_instructions() {
255        // Arrange
256        let prompt = "Implement feature";
257
258        // Act
259        let rendered_prompt = prepend_protocol_instructions(
260            prompt,
261            ProtocolRequestProfile::SessionTurn,
262            ProtocolSchemaInstructionMode::PromptSchema,
263            test_workspace_root(),
264        );
265
266        let normalized_prompt = normalize_prompt(&rendered_prompt);
267        let protocol_position = rendered_prompt
268            .find("Structured response protocol:")
269            .expect("protocol marker should be present");
270        let schema_position = rendered_prompt
271            .find("Authoritative JSON Schema:")
272            .expect("schema should be present");
273        let user_prompt_position = rendered_prompt
274            .rfind(prompt)
275            .expect("user prompt should be present");
276
277        // Assert
278        assert!(rendered_prompt.contains("File path output requirements:"));
279        assert!(rendered_prompt.contains("Workspace isolation requirements:"));
280        assert!(protocol_position < schema_position);
281        assert!(schema_position < user_prompt_position);
282        assert!(rendered_prompt.contains("`/tmp/agentty-wt/session-1`"));
283        assert!(normalized_prompt.contains("process working directory"));
284        assert!(normalized_prompt.contains("everything outside it is read-only"));
285        assert!(rendered_prompt.contains("repository-root-relative POSIX paths"));
286        assert!(rendered_prompt.contains("`path:line:column`"));
287        assert!(normalized_prompt.contains("absolute paths, `file://` URIs, or `../` prefixes"));
288        assert!(normalized_prompt.contains("Git commands must be read-only"));
289        assert!(normalized_prompt.contains("Never run mutating commands"));
290        assert!(rendered_prompt.contains("`git worktree remove`"));
291        assert!(rendered_prompt.contains("`cd`, `git -C`"));
292        assert!(rendered_prompt.contains("Quality check requirements:"));
293        assert!(rendered_prompt.contains("repository-defined checks"));
294        assert!(normalized_prompt.contains("affected dependencies and dependents"));
295        assert!(normalized_prompt.contains("full repository test/check suite"));
296        assert!(normalized_prompt.contains("session-created temporary scripts and files"));
297        assert!(rendered_prompt.contains("Structured response protocol:"));
298        assert!(normalized_prompt.contains("exactly one JSON object"));
299        assert!(normalized_prompt.contains("without markdown fences or surrounding prose"));
300        assert!(normalized_prompt.contains("Follow this JSON Schema exactly"));
301        assert!(normalized_prompt.contains("titles and descriptions are authoritative"));
302        assert!(rendered_prompt.contains("Authoritative JSON Schema:"));
303        assert!(
304            rendered_prompt
305                .contains("______________________________________________________________________")
306        );
307        assert!(!rendered_prompt.contains("{# task separator #}"));
308        assert!(rendered_prompt.contains("For this session turn:"));
309        assert!(rendered_prompt.contains("```mermaid"));
310        assert!(normalized_prompt.contains("diagram only in `answer`"));
311        assert!(normalized_prompt.contains("opening fence starts in column 1"));
312        assert!(normalized_prompt.contains("exactly three backticks"));
313        assert!(
314            normalized_prompt.contains("Other fences, indented blocks, and plain-text Mermaid")
315        );
316        assert!(normalized_prompt.contains("`graph`/`flowchart` with `TD`, `TB`, or `LR`"));
317        assert!(normalized_prompt.contains("32 plain-ASCII characters"));
318        assert!(normalized_prompt.contains("at most 16 nodes and 24 edges"));
319        assert!(normalized_prompt.contains("at most 4 sequence participants"));
320        assert!(normalized_prompt.contains("double-width glyphs suppress the preview"));
321        assert!(normalized_prompt.contains("feedback edge as a separate return row"));
322        assert!(normalized_prompt.contains("fall back to plain fenced code"));
323        assert!(normalized_prompt.contains("Do not create commits; do not suggest creating them"));
324        assert!(normalized_prompt.contains("Leave `subtasks` empty unless"));
325        assert!(normalized_prompt.contains("Emit `review_comment_outcomes` only"));
326        assert!(normalized_prompt.contains("otherwise use an empty array"));
327        assert!(rendered_prompt.contains("\"answer\""));
328        assert!(rendered_prompt.contains("\"questions\""));
329        assert!(rendered_prompt.contains("\"title\""));
330        assert!(rendered_prompt.contains("\"description\""));
331        assert!(rendered_prompt.ends_with(prompt));
332    }
333
334    #[test]
335    /// Ensures schema-enforcing transports get protocol policy without the
336    /// large prompt-side JSON Schema body.
337    fn test_prepend_protocol_instructions_omits_schema_for_transport_schema_mode() {
338        // Arrange
339        let prompt = "Implement feature";
340
341        // Act
342        let rendered_prompt = prepend_protocol_instructions(
343            prompt,
344            ProtocolRequestProfile::SessionTurn,
345            ProtocolSchemaInstructionMode::TransportSchema,
346            test_workspace_root(),
347        );
348
349        let normalized_prompt = normalize_prompt(&rendered_prompt);
350
351        // Assert
352        assert!(rendered_prompt.contains("Structured response protocol:"));
353        assert!(rendered_prompt.contains("Workspace isolation requirements:"));
354        assert!(rendered_prompt.contains("`/tmp/agentty-wt/session-1`"));
355        assert!(normalized_prompt.contains("everything outside it is read-only"));
356        assert!(rendered_prompt.contains("provider enforces the response JSON schema"));
357        assert!(normalized_prompt.contains("exactly one JSON object"));
358        assert!(!rendered_prompt.contains("Follow this JSON Schema exactly."));
359        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
360        assert!(rendered_prompt.ends_with(prompt));
361    }
362
363    #[test]
364    /// Ensures protocol instructions are not duplicated when already present.
365    fn test_prepend_protocol_instructions_is_idempotent() {
366        // Arrange
367        let prompt = prepend_protocol_instructions(
368            "Implement feature",
369            ProtocolRequestProfile::SessionTurn,
370            ProtocolSchemaInstructionMode::PromptSchema,
371            test_workspace_root(),
372        );
373
374        // Act
375        let rendered_prompt = prepend_protocol_instructions(
376            &prompt,
377            ProtocolRequestProfile::UtilityPrompt,
378            ProtocolSchemaInstructionMode::TransportSchema,
379            test_workspace_root(),
380        );
381
382        // Assert
383        assert_eq!(rendered_prompt, prompt);
384    }
385
386    #[test]
387    /// Ensures one-shot prompts reuse the shared full-schema protocol
388    /// instructions.
389    fn test_prepend_protocol_instructions_reuses_same_contract_for_one_shot() {
390        // Arrange
391        let prompt = "Generate title";
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.contains("Structured response protocol:"));
403        assert!(
404            rendered_prompt
405                .contains("______________________________________________________________________")
406        );
407        assert!(rendered_prompt.contains("For this one-shot utility prompt"));
408        assert!(!rendered_prompt.contains("For this session turn:"));
409        assert!(!rendered_prompt.contains("mermaid"));
410        assert!(rendered_prompt.contains(
411            r#"{"answer":"...","questions":[],"review_comment_outcomes":[],"summary":null}"#
412        ));
413        assert!(rendered_prompt.contains("\"review_comment_outcomes\""));
414        assert!(rendered_prompt.contains("\"summary\""));
415        assert!(rendered_prompt.ends_with(prompt));
416    }
417
418    #[test]
419    /// Ensures user prompt text is inserted after generated protocol
420    /// placeholders so prompt content cannot trigger recursive expansion.
421    fn test_prepend_protocol_instructions_preserves_prompt_placeholders() {
422        // Arrange
423        let prompt = "Keep these literal: {{ response_json_schema }} {{ \
424                      protocol_usage_instructions }} {{ workspace_root }}";
425
426        // Act
427        let rendered_prompt = prepend_protocol_instructions(
428            prompt,
429            ProtocolRequestProfile::UtilityPrompt,
430            ProtocolSchemaInstructionMode::PromptSchema,
431            test_workspace_root(),
432        );
433
434        // Assert
435        assert!(rendered_prompt.ends_with(prompt));
436    }
437
438    #[test]
439    /// Ensures compact refresh reminders omit the full schema while keeping
440    /// the contract reminder and task body.
441    fn test_prepend_protocol_refresh_reminder_adds_compact_contract_notice() {
442        // Arrange
443        let prompt = "Continue the implementation";
444
445        // Act
446        let rendered_prompt = prepend_protocol_refresh_reminder(
447            prompt,
448            ProtocolRequestProfile::SessionTurn,
449            test_workspace_root(),
450        );
451        let normalized_prompt = normalize_prompt(&rendered_prompt);
452
453        // Assert
454        assert!(rendered_prompt.contains("Protocol refresh reminder:"));
455        assert!(rendered_prompt.contains("repository-root-relative POSIX"));
456        assert!(normalized_prompt.contains("only read-only git commands; never mutating ones"));
457        assert!(rendered_prompt.contains("inside `/tmp/agentty-wt/session-1`"));
458        assert!(normalized_prompt.contains("everything outside this workspace root is read-only"));
459        assert!(normalized_prompt.contains("Keep Mermaid in `answer`"));
460        assert!(normalized_prompt.contains("fences lacking the `mermaid` info string"));
461        assert!(
462            rendered_prompt
463                .contains("______________________________________________________________________")
464        );
465        assert!(!rendered_prompt.contains("Authoritative JSON Schema:"));
466        assert!(rendered_prompt.ends_with(prompt));
467    }
468
469    #[test]
470    /// Ensures refresh prompt text is inserted after generated reminder
471    /// placeholders so prompt content cannot trigger recursive expansion.
472    fn test_prepend_protocol_refresh_reminder_preserves_prompt_placeholders() {
473        // Arrange
474        let prompt = "Keep this literal: {{ protocol_refresh_instructions }} {{ workspace_root }}";
475
476        // Act
477        let rendered_prompt = prepend_protocol_refresh_reminder(
478            prompt,
479            ProtocolRequestProfile::SessionTurn,
480            test_workspace_root(),
481        );
482
483        // Assert
484        assert!(rendered_prompt.ends_with(prompt));
485    }
486
487    #[test]
488    /// Ensures utility refreshes retain their one-shot profile without
489    /// session-only field or Mermaid guidance.
490    fn test_prepend_protocol_refresh_reminder_uses_utility_profile() {
491        // Arrange
492        let prompt = "Generate another title";
493
494        // Act
495        let rendered_prompt = prepend_protocol_refresh_reminder(
496            prompt,
497            ProtocolRequestProfile::UtilityPrompt,
498            test_workspace_root(),
499        );
500
501        // Assert
502        assert!(rendered_prompt.contains("bootstrapped one-shot JSON object shape"));
503        assert!(!rendered_prompt.contains("`review_comment_outcomes`"));
504        assert!(!rendered_prompt.contains("```mermaid"));
505        assert!(rendered_prompt.ends_with(prompt));
506    }
507
508    #[test]
509    /// Repair prompt renders with the parse error and a response preview.
510    fn test_build_protocol_repair_prompt_includes_error_and_preview() {
511        // Arrange
512        let parse_error = "response is not valid protocol JSON: invalid JSON";
513        let malformed_response = "plain text response";
514
515        // Act
516        let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
517
518        // Assert
519        assert!(repair_prompt.contains(parse_error));
520        assert!(repair_prompt.contains("plain text response"));
521        assert!(repair_prompt.contains("Structured response protocol:"));
522        assert!(repair_prompt.contains("Authoritative JSON Schema:"));
523        assert!(repair_prompt.contains("\"answer\""));
524    }
525
526    #[test]
527    /// Ensures malformed response previews are inserted after generated
528    /// schema placeholders so agent output cannot trigger recursive expansion.
529    fn test_build_protocol_repair_prompt_preserves_response_preview_placeholders() {
530        // Arrange
531        let malformed_response = "Keep this literal: {{ response_json_schema }}";
532
533        // Act
534        let repair_prompt =
535            build_protocol_repair_prompt("schema validation failed", malformed_response);
536
537        // Assert
538        assert!(repair_prompt.contains(malformed_response));
539    }
540
541    #[test]
542    /// Repair prompt truncates long malformed responses to the preview limit.
543    fn test_build_protocol_repair_prompt_truncates_long_response() {
544        // Arrange
545        let parse_error = "schema validation failed";
546        let malformed_response = "x".repeat(1000);
547
548        // Act
549        let repair_prompt = build_protocol_repair_prompt(parse_error, &malformed_response);
550
551        // Assert
552        assert!(repair_prompt.contains("500 more chars"));
553        assert!(!repair_prompt.contains(&malformed_response));
554    }
555
556    #[test]
557    /// Repair prompt includes the protocol marker to prevent double-wrapping.
558    fn test_build_protocol_repair_prompt_contains_protocol_marker() {
559        // Arrange / Act
560        let repair_prompt = build_protocol_repair_prompt("error", "response");
561
562        // Assert
563        assert!(repair_prompt.contains("Structured response protocol:"));
564    }
565
566    #[test]
567    /// Short responses are not truncated.
568    fn test_truncate_preview_keeps_short_responses_intact() {
569        // Arrange / Act
570        let preview = truncate_preview("short", 500);
571
572        // Assert
573        assert_eq!(preview, "short");
574    }
575
576    #[test]
577    /// Long responses are truncated with a character count suffix.
578    fn test_truncate_preview_truncates_long_responses() {
579        // Arrange
580        let long_response = "a".repeat(600);
581
582        // Act
583        let preview = truncate_preview(&long_response, 500);
584
585        // Assert
586        assert!(preview.starts_with(&"a".repeat(500)));
587        assert!(preview.contains("100 more chars"));
588    }
589}