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. The error carries the parse reason and derived
81/// diagnostics only: turn errors are rendered into the session transcript, so
82/// quoting the payload would print raw provider output into the chat.
83pub(crate) fn parse_turn_response(
84    kind: AgentKind,
85    response_text: &str,
86    protocol_profile: ProtocolRequestProfile,
87) -> Result<AgentResponse, String> {
88    let response = parse_agent_response_strict(response_text).map_err(|error| {
89        format!(
90            "Agent output did not match the required JSON schema from {kind}: \
91             {error}\nprotocol_profile: {protocol_profile:?}\ndebug_details:\n{}",
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 => {
130            prompt::build_prompt_stdin_payload(request, protocol_schema_instruction_mode, "Claude")
131                .map(Some)
132        }
133    }
134}
135
136/// One backend/provider descriptor containing construction and parsing hooks.
137struct AgentProviderDescriptor {
138    app_server_client_factory: AppServerClientFactory,
139    app_server_thought_policy: AppServerThoughtPolicy,
140    backend_factory: fn() -> Box<dyn AgentBackend>,
141    parse_response: fn(&str, &str) -> ParsedResponse,
142    parse_stream_output_line: fn(&str) -> Option<(String, bool)>,
143    prompt_transport: AgentPromptTransport,
144    protocol_schema_instruction_mode: ProtocolSchemaInstructionMode,
145    transport: AgentTransport,
146}
147
148fn provider_descriptor(kind: AgentKind) -> AgentProviderDescriptor {
149    match kind {
150        AgentKind::Antigravity => AgentProviderDescriptor {
151            app_server_client_factory: |_default_client| None,
152            app_server_thought_policy: AppServerThoughtPolicy::None,
153            backend_factory: || Box::new(super::antigravity::AntigravityBackend::new()),
154            parse_response: super::response_parser::parse_antigravity_response_with_fallback,
155            parse_stream_output_line: super::response_parser::parse_antigravity_stream_output_line,
156            prompt_transport: AgentPromptTransport::Argv,
157            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::TransportSchema,
158            transport: AgentTransport::Cli,
159        },
160        AgentKind::Gemini => AgentProviderDescriptor {
161            app_server_client_factory: |default_client| {
162                Some(default_client.unwrap_or_else(|| {
163                    Arc::new(super::app_server::RealGeminiAcpClient::new())
164                        as Arc<dyn AppServerClient>
165                }))
166            },
167            app_server_thought_policy: AppServerThoughtPolicy::None,
168            backend_factory: || Box::new(super::gemini::GeminiBackend),
169            parse_response: super::response_parser::parse_gemini_response_with_fallback,
170            parse_stream_output_line: super::response_parser::parse_gemini_stream_output_line,
171            prompt_transport: AgentPromptTransport::Argv,
172            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::PromptSchema,
173            transport: AgentTransport::AppServer,
174        },
175        AgentKind::Claude => AgentProviderDescriptor {
176            app_server_client_factory: |_default_client| None,
177            app_server_thought_policy: AppServerThoughtPolicy::None,
178            backend_factory: || Box::new(super::claude::ClaudeBackend),
179            parse_response: super::response_parser::parse_claude_response_with_fallback,
180            parse_stream_output_line: super::response_parser::parse_claude_stream_output_line,
181            prompt_transport: AgentPromptTransport::Stdin,
182            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::TransportSchema,
183            transport: AgentTransport::Cli,
184        },
185        AgentKind::Codex => AgentProviderDescriptor {
186            app_server_client_factory: |default_client| {
187                Some(default_client.unwrap_or_else(|| {
188                    Arc::new(super::app_server::RealCodexAppServerClient::new())
189                        as Arc<dyn AppServerClient>
190                }))
191            },
192            app_server_thought_policy: AppServerThoughtPolicy::PhaseLabel,
193            backend_factory: || Box::new(super::codex::CodexBackend),
194            parse_response: super::response_parser::parse_codex_response_with_fallback,
195            parse_stream_output_line: super::response_parser::parse_codex_stream_output_line,
196            prompt_transport: AgentPromptTransport::Argv,
197            protocol_schema_instruction_mode: ProtocolSchemaInstructionMode::TransportSchema,
198            transport: AgentTransport::AppServer,
199        },
200    }
201}
202
203/// Returns whether one Codex phase label denotes thought/planning text.
204///
205/// Phase matching is case-insensitive so provider variants such as `Thinking`
206/// and `PLAN` continue to route to thought deltas.
207fn is_codex_thought_phase_label(phase: &str) -> bool {
208    let normalized_phase = phase.trim();
209
210    normalized_phase.eq_ignore_ascii_case("thinking")
211        || normalized_phase.eq_ignore_ascii_case("plan")
212        || normalized_phase.eq_ignore_ascii_case("reasoning")
213        || normalized_phase.eq_ignore_ascii_case("thought")
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    /// Ensures transport capability is provided by infra backend descriptors,
222    /// not domain enums.
223    fn test_transport_mode_reports_expected_transport_by_provider() {
224        // Arrange
225        let antigravity_kind = AgentKind::Antigravity;
226        let claude_kind = AgentKind::Claude;
227        let codex_kind = AgentKind::Codex;
228        let gemini_kind = AgentKind::Gemini;
229
230        // Act
231        let antigravity_transport = transport_mode(antigravity_kind);
232        let claude_transport = transport_mode(claude_kind);
233        let codex_transport = transport_mode(codex_kind);
234        let gemini_transport = transport_mode(gemini_kind);
235
236        // Assert
237        assert_eq!(antigravity_transport, AgentTransport::Cli);
238        assert_eq!(claude_transport, AgentTransport::Cli);
239        assert_eq!(codex_transport, AgentTransport::AppServer);
240        assert_eq!(gemini_transport, AgentTransport::AppServer);
241    }
242
243    #[test]
244    /// Ensures prompt delivery is also derived from the shared provider
245    /// descriptor.
246    fn test_prompt_transport_reports_expected_mode_by_provider() {
247        // Arrange
248        let antigravity_kind = AgentKind::Antigravity;
249        let claude_kind = AgentKind::Claude;
250        let codex_kind = AgentKind::Codex;
251        let gemini_kind = AgentKind::Gemini;
252
253        // Act
254        let antigravity_transport = prompt_transport(antigravity_kind);
255        let claude_transport = prompt_transport(claude_kind);
256        let codex_transport = prompt_transport(codex_kind);
257        let gemini_transport = prompt_transport(gemini_kind);
258
259        // Assert
260        assert_eq!(antigravity_transport, AgentPromptTransport::Argv);
261        assert_eq!(claude_transport, AgentPromptTransport::Stdin);
262        assert_eq!(codex_transport, AgentPromptTransport::Argv);
263        assert_eq!(gemini_transport, AgentPromptTransport::Argv);
264    }
265
266    #[test]
267    /// Ensures provider schema capabilities are derived from the shared
268    /// descriptor.
269    fn test_protocol_schema_instruction_mode_reports_expected_mode_by_provider() {
270        // Arrange / Act / Assert
271        assert_eq!(
272            protocol_schema_instruction_mode(AgentKind::Antigravity),
273            ProtocolSchemaInstructionMode::TransportSchema
274        );
275        assert_eq!(
276            protocol_schema_instruction_mode(AgentKind::Gemini),
277            ProtocolSchemaInstructionMode::PromptSchema
278        );
279        assert_eq!(
280            protocol_schema_instruction_mode(AgentKind::Claude),
281            ProtocolSchemaInstructionMode::TransportSchema
282        );
283        assert_eq!(
284            protocol_schema_instruction_mode(AgentKind::Codex),
285            ProtocolSchemaInstructionMode::TransportSchema
286        );
287    }
288
289    #[test]
290    /// Ensures providers reject malformed final protocol payloads.
291    fn test_parse_turn_response_rejects_invalid_payload() {
292        // Arrange
293        let raw_response = "plain response";
294
295        for kind in [
296            AgentKind::Antigravity,
297            AgentKind::Claude,
298            AgentKind::Codex,
299            AgentKind::Gemini,
300        ] {
301            // Act
302            let error =
303                parse_turn_response(kind, raw_response, ProtocolRequestProfile::SessionTurn)
304                    .expect_err("plain response should fail strict protocol parsing");
305
306            // Assert
307            assert!(error.contains("debug_details:"));
308            assert!(error.contains("first_non_whitespace_char: 'p'"));
309            assert!(error.contains("direct_json_error_location: line 1, column 1"));
310        }
311    }
312
313    #[test]
314    /// Ensures valid session-turn payloads still gain an empty summary when
315    /// the response omits it.
316    fn test_parse_turn_response_fills_missing_summary_for_session_turn() {
317        // Arrange
318        let raw_response = r#"{"answer":"done","questions":[],"summary":null}"#;
319
320        // Act
321        let result = parse_turn_response(
322            AgentKind::Codex,
323            raw_response,
324            ProtocolRequestProfile::SessionTurn,
325        )
326        .expect("valid protocol response should parse");
327
328        // Assert
329        assert_eq!(result.answer, "done");
330        assert_eq!(
331            result.summary,
332            Some(AgentResponseSummary {
333                session: String::new(),
334                turn: String::new(),
335            })
336        );
337    }
338
339    #[test]
340    /// Ensures Codex app-server phase labels map to thought deltas through the
341    /// shared provider descriptor.
342    fn test_is_app_server_thought_chunk_reports_codex_phase_labels() {
343        // Arrange / Act / Assert
344        assert!(is_app_server_thought_chunk(
345            AgentKind::Codex,
346            true,
347            Some("thinking"),
348        ));
349        assert!(!is_app_server_thought_chunk(
350            AgentKind::Gemini,
351            true,
352            Some("thinking"),
353        ));
354    }
355}