Skip to main content

ag_agent/agent/
provider.rs

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