Skip to main content

ag_agent/agent/
provider.rs

1//! Shared provider registry and transport policy descriptors.
2
3use std::sync::Arc;
4
5#[cfg(test)]
6use ag_protocol::AgentResponseSummary;
7use ag_protocol::{
8    AgentResponse, ProtocolRequestProfile, ProtocolSchemaInstructionMode,
9    format_protocol_parse_debug_details, normalize_turn_response, parse_agent_response_strict,
10};
11
12use super::backend::{
13    AgentBackend, AgentBackendError, AgentPromptTransport, AgentTransport, AppServerThoughtPolicy,
14    BuildCommandRequest,
15};
16use super::prompt;
17use super::response_parser::ParsedResponse;
18use crate::app_server::AppServerClient;
19use crate::model::agent::AgentKind;
20
21/// Factory hook used to build or override provider-specific app-server
22/// clients.
23type AppServerClientFactory =
24    fn(Option<Arc<dyn AppServerClient>>) -> Option<Arc<dyn AppServerClient>>;
25
26/// Creates the backend implementation for the selected agent provider.
27pub fn create_backend(kind: AgentKind) -> Box<dyn AgentBackend> {
28    (provider_descriptor(kind).backend_factory)()
29}
30
31/// Returns the app-server client for the selected provider when applicable.
32pub fn create_app_server_client(
33    kind: AgentKind,
34    default_client: Option<Arc<dyn AppServerClient>>,
35) -> Option<Arc<dyn AppServerClient>> {
36    (provider_descriptor(kind).app_server_client_factory)(default_client)
37}
38
39/// Parses provider output and returns final response content and usage stats.
40pub(crate) fn parse_response(kind: AgentKind, stdout: &str, stderr: &str) -> ParsedResponse {
41    (provider_descriptor(kind).parse_response)(stdout, stderr)
42}
43
44/// Parses one stream line into incremental text and content classification.
45///
46/// Returns `(text, is_response_content)` where `is_response_content` is `true`
47/// for model-authored content and `false` for progress updates.
48pub(crate) fn parse_stream_output_line(
49    kind: AgentKind,
50    stdout_line: &str,
51) -> Option<(String, bool)> {
52    (provider_descriptor(kind).parse_stream_output_line)(stdout_line)
53}
54
55/// Returns transport mode for the selected provider.
56pub fn transport_mode(kind: AgentKind) -> AgentTransport {
57    provider_descriptor(kind).transport
58}
59
60/// Returns whether the provider expects prompts through stdin.
61pub(crate) fn prompt_transport(kind: AgentKind) -> AgentPromptTransport {
62    provider_descriptor(kind).prompt_transport
63}
64
65/// Returns whether bootstrap prompts should include schema text for the
66/// selected provider.
67///
68/// Providers that enforce Agentty's response shape natively still receive
69/// policy and field-routing instructions, but skip the large prompt-side JSON
70/// Schema to avoid redundant tokens.
71pub(crate) fn protocol_schema_instruction_mode(kind: AgentKind) -> ProtocolSchemaInstructionMode {
72    provider_descriptor(kind).protocol_schema_instruction_mode
73}
74
75/// Parses one final assistant payload strictly against the shared protocol and
76/// normalizes it for the active request profile.
77///
78/// # Errors
79/// Returns a descriptive error when provider output does not match the
80/// required protocol JSON, including parse diagnostics that help explain why
81/// the payload was rejected.
82pub(crate) fn parse_turn_response(
83    kind: AgentKind,
84    response_text: &str,
85    protocol_profile: ProtocolRequestProfile,
86) -> Result<AgentResponse, String> {
87    let response = parse_agent_response_strict(response_text).map_err(|error| {
88        format!(
89            "Agent output did not match the required JSON schema from {kind}: \
90             {error}\nprotocol_profile: \
91             {protocol_profile:?}\ndebug_details:\n{}\nresponse:\n{response_text}",
92            format_protocol_parse_debug_details(response_text)
93        )
94    })?;
95
96    Ok(normalize_turn_response(response, protocol_profile))
97}
98
99/// Returns whether one app-server assistant chunk should be treated as
100/// thought text instead of transcript output.
101pub(crate) fn is_app_server_thought_chunk(
102    kind: AgentKind,
103    is_delta: bool,
104    phase: Option<&str>,
105) -> bool {
106    if !is_delta {
107        return false;
108    }
109
110    match provider_descriptor(kind).app_server_thought_policy {
111        AppServerThoughtPolicy::None => false,
112        AppServerThoughtPolicy::PhaseLabel => phase.is_some_and(is_codex_thought_phase_label),
113    }
114}
115
116/// Builds one optional stdin payload for providers that stream prompts instead
117/// of sending them through argv.
118///
119/// # Errors
120/// Returns an error when provider-specific prompt rendering fails.
121pub(crate) fn build_command_stdin_payload(
122    kind: AgentKind,
123    request: BuildCommandRequest<'_>,
124) -> Result<Option<Vec<u8>>, AgentBackendError> {
125    let protocol_schema_instruction_mode = protocol_schema_instruction_mode(kind);
126
127    match prompt_transport(kind) {
128        AgentPromptTransport::Argv => Ok(None),
129        AgentPromptTransport::Stdin => match kind {
130            AgentKind::Antigravity => prompt::build_prompt_stdin_payload(
131                request,
132                protocol_schema_instruction_mode,
133                "Antigravity",
134            )
135            .map(Some),
136            AgentKind::Claude => prompt::build_prompt_stdin_payload(
137                request,
138                protocol_schema_instruction_mode,
139                "Claude",
140            )
141            .map(Some),
142            AgentKind::Codex | AgentKind::Gemini => Ok(None),
143        },
144    }
145}
146
147/// One backend/provider descriptor containing construction and parsing hooks.
148struct AgentProviderDescriptor {
149    app_server_client_factory: AppServerClientFactory,
150    app_server_thought_policy: AppServerThoughtPolicy,
151    backend_factory: fn() -> Box<dyn AgentBackend>,
152    parse_response: fn(&str, &str) -> ParsedResponse,
153    parse_stream_output_line: fn(&str) -> Option<(String, bool)>,
154    prompt_transport: AgentPromptTransport,
155    protocol_schema_instruction_mode: ProtocolSchemaInstructionMode,
156    transport: AgentTransport,
157}
158
159fn provider_descriptor(kind: AgentKind) -> AgentProviderDescriptor {
160    match kind {
161        AgentKind::Antigravity => AgentProviderDescriptor {
162            app_server_client_factory: |_default_client| None,
163            app_server_thought_policy: AppServerThoughtPolicy::None,
164            backend_factory: || Box::new(super::antigravity::AntigravityBackend),
165            parse_response: super::response_parser::parse_antigravity_response_with_fallback,
166            parse_stream_output_line: super::response_parser::parse_antigravity_stream_output_line,
167            prompt_transport: AgentPromptTransport::Stdin,
168            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
169            transport: AgentTransport::Cli,
170        },
171        AgentKind::Gemini => AgentProviderDescriptor {
172            app_server_client_factory: |default_client| {
173                Some(default_client.unwrap_or_else(|| {
174                    Arc::new(super::app_server::RealGeminiAcpClient::new())
175                        as Arc<dyn AppServerClient>
176                }))
177            },
178            app_server_thought_policy: AppServerThoughtPolicy::None,
179            backend_factory: || Box::new(super::gemini::GeminiBackend),
180            parse_response: super::response_parser::parse_gemini_response_with_fallback,
181            parse_stream_output_line: super::response_parser::parse_gemini_stream_output_line,
182            prompt_transport: AgentPromptTransport::Argv,
183            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
184            transport: AgentTransport::AppServer,
185        },
186        AgentKind::Claude => AgentProviderDescriptor {
187            app_server_client_factory: |_default_client| None,
188            app_server_thought_policy: AppServerThoughtPolicy::None,
189            backend_factory: || Box::new(super::claude::ClaudeBackend),
190            parse_response: super::response_parser::parse_claude_response_with_fallback,
191            parse_stream_output_line: super::response_parser::parse_claude_stream_output_line,
192            prompt_transport: AgentPromptTransport::Stdin,
193            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::TransportSchema,
194            transport: AgentTransport::Cli,
195        },
196        AgentKind::Codex => AgentProviderDescriptor {
197            app_server_client_factory: |default_client| {
198                Some(default_client.unwrap_or_else(|| {
199                    Arc::new(super::app_server::RealCodexAppServerClient::new())
200                        as Arc<dyn AppServerClient>
201                }))
202            },
203            app_server_thought_policy: AppServerThoughtPolicy::PhaseLabel,
204            backend_factory: || Box::new(super::codex::CodexBackend),
205            parse_response: super::response_parser::parse_codex_response_with_fallback,
206            parse_stream_output_line: super::response_parser::parse_codex_stream_output_line,
207            prompt_transport: AgentPromptTransport::Argv,
208            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::TransportSchema,
209            transport: AgentTransport::AppServer,
210        },
211    }
212}
213
214/// Returns whether one Codex phase label denotes thought/planning text.
215///
216/// Phase matching is case-insensitive so provider variants such as `Thinking`
217/// and `PLAN` continue to route to thought deltas.
218fn is_codex_thought_phase_label(phase: &str) -> bool {
219    let normalized_phase = phase.trim();
220
221    normalized_phase.eq_ignore_ascii_case("thinking")
222        || normalized_phase.eq_ignore_ascii_case("plan")
223        || normalized_phase.eq_ignore_ascii_case("reasoning")
224        || normalized_phase.eq_ignore_ascii_case("thought")
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    /// Ensures transport capability is provided by infra backend descriptors,
233    /// not domain enums.
234    fn test_transport_mode_reports_expected_transport_by_provider() {
235        // Arrange
236        let antigravity_kind = AgentKind::Antigravity;
237        let claude_kind = AgentKind::Claude;
238        let codex_kind = AgentKind::Codex;
239        let gemini_kind = AgentKind::Gemini;
240
241        // Act
242        let antigravity_transport = transport_mode(antigravity_kind);
243        let claude_transport = transport_mode(claude_kind);
244        let codex_transport = transport_mode(codex_kind);
245        let gemini_transport = transport_mode(gemini_kind);
246
247        // Assert
248        assert_eq!(antigravity_transport, AgentTransport::Cli);
249        assert_eq!(claude_transport, AgentTransport::Cli);
250        assert_eq!(codex_transport, AgentTransport::AppServer);
251        assert_eq!(gemini_transport, AgentTransport::AppServer);
252    }
253
254    #[test]
255    /// Ensures prompt delivery is also derived from the shared provider
256    /// descriptor.
257    fn test_prompt_transport_reports_expected_mode_by_provider() {
258        // Arrange
259        let antigravity_kind = AgentKind::Antigravity;
260        let claude_kind = AgentKind::Claude;
261        let codex_kind = AgentKind::Codex;
262        let gemini_kind = AgentKind::Gemini;
263
264        // Act
265        let antigravity_transport = prompt_transport(antigravity_kind);
266        let claude_transport = prompt_transport(claude_kind);
267        let codex_transport = prompt_transport(codex_kind);
268        let gemini_transport = prompt_transport(gemini_kind);
269
270        // Assert
271        assert_eq!(antigravity_transport, AgentPromptTransport::Stdin);
272        assert_eq!(claude_transport, AgentPromptTransport::Stdin);
273        assert_eq!(codex_transport, AgentPromptTransport::Argv);
274        assert_eq!(gemini_transport, AgentPromptTransport::Argv);
275    }
276
277    #[test]
278    /// Ensures provider schema capabilities are derived from the shared
279    /// descriptor.
280    fn test_protocol_schema_instruction_mode_reports_expected_mode_by_provider() {
281        // Arrange / Act / Assert
282        assert_eq!(
283            protocol_schema_instruction_mode(AgentKind::Antigravity),
284            ProtocolSchemaInstructionMode::PromptSchema
285        );
286        assert_eq!(
287            protocol_schema_instruction_mode(AgentKind::Gemini),
288            ProtocolSchemaInstructionMode::PromptSchema
289        );
290        assert_eq!(
291            protocol_schema_instruction_mode(AgentKind::Claude),
292            ProtocolSchemaInstructionMode::TransportSchema
293        );
294        assert_eq!(
295            protocol_schema_instruction_mode(AgentKind::Codex),
296            ProtocolSchemaInstructionMode::TransportSchema
297        );
298    }
299
300    #[test]
301    /// Ensures providers reject malformed final protocol payloads.
302    fn test_parse_turn_response_rejects_invalid_payload() {
303        // Arrange
304        let raw_response = "plain response";
305
306        for kind in [
307            AgentKind::Antigravity,
308            AgentKind::Claude,
309            AgentKind::Codex,
310            AgentKind::Gemini,
311        ] {
312            // Act
313            let error =
314                parse_turn_response(kind, raw_response, ProtocolRequestProfile::SessionTurn)
315                    .expect_err("plain response should fail strict protocol parsing");
316
317            // Assert
318            assert!(error.contains("debug_details:"));
319            assert!(error.contains("first_non_whitespace_char: 'p'"));
320            assert!(error.contains("direct_json_error_location: line 1, column 1"));
321        }
322    }
323
324    #[test]
325    /// Ensures valid session-turn payloads still gain an empty summary when
326    /// the response omits it.
327    fn test_parse_turn_response_fills_missing_summary_for_session_turn() {
328        // Arrange
329        let raw_response = r#"{"answer":"done","questions":[],"summary":null}"#;
330
331        // Act
332        let result = parse_turn_response(
333            AgentKind::Codex,
334            raw_response,
335            ProtocolRequestProfile::SessionTurn,
336        )
337        .expect("valid protocol response should parse");
338
339        // Assert
340        assert_eq!(result.answer, "done");
341        assert_eq!(
342            result.summary,
343            Some(AgentResponseSummary {
344                session: String::new(),
345                turn: String::new(),
346            })
347        );
348    }
349
350    #[test]
351    /// Ensures Codex app-server phase labels map to thought deltas through the
352    /// shared provider descriptor.
353    fn test_is_app_server_thought_chunk_reports_codex_phase_labels() {
354        // Arrange / Act / Assert
355        assert!(is_app_server_thought_chunk(
356            AgentKind::Codex,
357            true,
358            Some("thinking"),
359        ));
360        assert!(!is_app_server_thought_chunk(
361            AgentKind::Gemini,
362            true,
363            Some("thinking"),
364        ));
365    }
366}