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