stravinsky 0.2.7__py3-none-any.whl → 0.2.40__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/cli.py +84 -46
  3. mcp_bridge/auth/oauth.py +88 -63
  4. mcp_bridge/hooks/__init__.py +29 -8
  5. mcp_bridge/hooks/agent_reminder.py +61 -0
  6. mcp_bridge/hooks/auto_slash_command.py +186 -0
  7. mcp_bridge/hooks/comment_checker.py +136 -0
  8. mcp_bridge/hooks/context_monitor.py +58 -0
  9. mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
  10. mcp_bridge/hooks/keyword_detector.py +122 -0
  11. mcp_bridge/hooks/manager.py +27 -8
  12. mcp_bridge/hooks/preemptive_compaction.py +157 -0
  13. mcp_bridge/hooks/session_recovery.py +186 -0
  14. mcp_bridge/hooks/todo_enforcer.py +75 -0
  15. mcp_bridge/hooks/truncator.py +1 -1
  16. mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
  17. mcp_bridge/native_hooks/truncator.py +1 -1
  18. mcp_bridge/prompts/delphi.py +3 -2
  19. mcp_bridge/prompts/dewey.py +105 -21
  20. mcp_bridge/prompts/stravinsky.py +451 -127
  21. mcp_bridge/server.py +304 -38
  22. mcp_bridge/server_tools.py +21 -3
  23. mcp_bridge/tools/__init__.py +2 -1
  24. mcp_bridge/tools/agent_manager.py +313 -236
  25. mcp_bridge/tools/init.py +1 -1
  26. mcp_bridge/tools/model_invoke.py +534 -52
  27. mcp_bridge/tools/skill_loader.py +51 -47
  28. mcp_bridge/tools/task_runner.py +74 -30
  29. mcp_bridge/tools/templates.py +101 -12
  30. {stravinsky-0.2.7.dist-info → stravinsky-0.2.40.dist-info}/METADATA +6 -12
  31. stravinsky-0.2.40.dist-info/RECORD +57 -0
  32. stravinsky-0.2.7.dist-info/RECORD +0 -47
  33. {stravinsky-0.2.7.dist-info → stravinsky-0.2.40.dist-info}/WHEEL +0 -0
  34. {stravinsky-0.2.7.dist-info → stravinsky-0.2.40.dist-info}/entry_points.txt +0 -0
@@ -6,10 +6,40 @@ API requests to external model providers.
6
6
  """
7
7
 
8
8
  import logging
9
+ import os
9
10
  import time
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
14
+ # Model name mapping: user-friendly names -> Antigravity API model IDs
15
+ # Per API spec: https://github.com/NoeFabris/opencode-antigravity-auth/blob/main/docs/ANTIGRAVITY_API_SPEC.md
16
+ # VERIFIED GEMINI MODELS (as of 2025-12):
17
+ # - gemini-3-pro-high, gemini-3-pro-low
18
+ # NOTE: There is NO gemini-3-flash in the API - all flash aliases map to gemini-3-pro-low
19
+ # NOTE: Claude models should use Anthropic API directly, NOT Antigravity
20
+ GEMINI_MODEL_MAP = {
21
+ # Antigravity verified Gemini models (pass-through)
22
+ "gemini-3-pro-low": "gemini-3-pro-low",
23
+ "gemini-3-pro-high": "gemini-3-pro-high",
24
+ # Aliases for convenience (map to closest verified model)
25
+ "gemini-flash": "gemini-3-pro-low",
26
+ "gemini-3-flash": "gemini-3-pro-low", # NOT a real model - redirect to pro-low
27
+ "gemini-pro": "gemini-3-pro-low",
28
+ "gemini-3-pro": "gemini-3-pro-low",
29
+ "gemini": "gemini-3-pro-low", # Default gemini alias
30
+ # Legacy mappings (redirect to Antigravity models)
31
+ "gemini-2.0-flash": "gemini-3-pro-low",
32
+ "gemini-2.0-flash-001": "gemini-3-pro-low",
33
+ "gemini-2.0-pro": "gemini-3-pro-low",
34
+ "gemini-2.0-pro-exp": "gemini-3-pro-high",
35
+ }
36
+
37
+
38
+ def resolve_gemini_model(model: str) -> str:
39
+ """Resolve a user-friendly model name to the actual API model ID."""
40
+ return GEMINI_MODEL_MAP.get(model, model) # Pass through if not in map
41
+
42
+
13
43
  import httpx
14
44
  from tenacity import (
15
45
  retry,
@@ -19,35 +49,144 @@ from tenacity import (
19
49
  )
20
50
 
21
51
  from ..auth.token_store import TokenStore
22
- from ..auth.oauth import refresh_access_token as gemini_refresh, ANTIGRAVITY_HEADERS
52
+ from ..auth.oauth import (
53
+ refresh_access_token as gemini_refresh,
54
+ ANTIGRAVITY_HEADERS,
55
+ ANTIGRAVITY_ENDPOINTS,
56
+ ANTIGRAVITY_DEFAULT_PROJECT_ID,
57
+ ANTIGRAVITY_API_VERSION,
58
+ )
23
59
  from ..auth.openai_oauth import refresh_access_token as openai_refresh
24
60
  from ..hooks.manager import get_hook_manager
25
61
 
62
+ # ========================
63
+ # SESSION & HTTP MANAGEMENT
64
+ # ========================
65
+
66
+ # Session cache for thinking signature persistence across multi-turn conversations
67
+ # Key: conversation_key (or "default"), Value: session UUID
68
+ _SESSION_CACHE: dict[str, str] = {}
69
+
70
+ # Pooled HTTP client for connection reuse
71
+ _HTTP_CLIENT: httpx.AsyncClient | None = None
72
+
73
+
74
+ def _get_session_id(conversation_key: str | None = None) -> str:
75
+ """
76
+ Get or create persistent session ID for thinking signature caching.
77
+
78
+ Per Antigravity API: session IDs must persist across multi-turn to maintain
79
+ thinking signature cache. Creating new UUID per call breaks this.
80
+
81
+ Args:
82
+ conversation_key: Optional key to scope session (e.g., per-agent)
83
+
84
+ Returns:
85
+ Stable session UUID for this conversation
86
+ """
87
+ import uuid
88
+
89
+ key = conversation_key or "default"
90
+ if key not in _SESSION_CACHE:
91
+ _SESSION_CACHE[key] = str(uuid.uuid4())
92
+ return _SESSION_CACHE[key]
93
+
94
+
95
+ def clear_session_cache() -> None:
96
+ """Clear session cache (for thinking recovery on error)."""
97
+ _SESSION_CACHE.clear()
98
+
99
+
100
+ async def _get_http_client() -> httpx.AsyncClient:
101
+ """
102
+ Get or create pooled HTTP client for connection reuse.
103
+
104
+ Reusing a single client across requests improves performance
105
+ by maintaining connection pools.
106
+ """
107
+ global _HTTP_CLIENT
108
+ if _HTTP_CLIENT is None or _HTTP_CLIENT.is_closed:
109
+ _HTTP_CLIENT = httpx.AsyncClient(timeout=120.0)
110
+ return _HTTP_CLIENT
111
+
112
+
113
+ def _extract_gemini_response(data: dict) -> str:
114
+ """
115
+ Extract text from Gemini response, handling thinking blocks.
116
+
117
+ Per Antigravity API, responses may contain:
118
+ - text: Regular response text
119
+ - thought: Thinking block content (when thinkingConfig enabled)
120
+ - thoughtSignature: Signature for caching (ignored)
121
+
122
+ Args:
123
+ data: Raw API response JSON
124
+
125
+ Returns:
126
+ Extracted text, with thinking blocks formatted as <thinking>...</thinking>
127
+ """
128
+ try:
129
+ # Unwrap the outer "response" envelope if present
130
+ inner_response = data.get("response", data)
131
+ candidates = inner_response.get("candidates", [])
132
+
133
+ if not candidates:
134
+ return "No response generated"
135
+
136
+ content = candidates[0].get("content", {})
137
+ parts = content.get("parts", [])
138
+
139
+ if not parts:
140
+ return "No response parts"
141
+
142
+ text_parts = []
143
+ thinking_parts = []
144
+
145
+ for part in parts:
146
+ if "thought" in part:
147
+ thinking_parts.append(part["thought"])
148
+ elif "text" in part:
149
+ text_parts.append(part["text"])
150
+ # Skip thoughtSignature parts
151
+
152
+ # Combine results
153
+ result = "".join(text_parts)
154
+
155
+ # Prepend thinking blocks if present
156
+ if thinking_parts:
157
+ thinking_content = "".join(thinking_parts)
158
+ result = f"<thinking>\n{thinking_content}\n</thinking>\n\n{result}"
159
+
160
+ return result if result.strip() else "No response generated"
161
+
162
+ except (KeyError, IndexError, TypeError) as e:
163
+ return f"Error parsing response: {e}"
164
+
26
165
 
27
166
  async def _ensure_valid_token(token_store: TokenStore, provider: str) -> str:
28
167
  """
29
168
  Get a valid access token, refreshing if needed.
30
-
169
+
31
170
  Args:
32
171
  token_store: Token store
33
172
  provider: Provider name
34
-
173
+
35
174
  Returns:
36
175
  Valid access token
37
-
176
+
38
177
  Raises:
39
178
  ValueError: If not authenticated
40
179
  """
41
180
  # Check if token needs refresh (with 5 minute buffer)
42
181
  if token_store.needs_refresh(provider, buffer_seconds=300):
43
182
  token = token_store.get_token(provider)
44
-
183
+
45
184
  if not token or not token.get("refresh_token"):
46
185
  raise ValueError(
47
186
  f"Not authenticated with {provider}. "
48
187
  f"Run: python -m mcp_bridge.auth.cli login {provider}"
49
188
  )
50
-
189
+
51
190
  try:
52
191
  if provider == "gemini":
53
192
  result = gemini_refresh(token["refresh_token"])
@@ -55,7 +194,7 @@ async def _ensure_valid_token(token_store: TokenStore, provider: str) -> str:
55
194
  result = openai_refresh(token["refresh_token"])
56
195
  else:
57
196
  raise ValueError(f"Unknown provider: {provider}")
58
-
197
+
59
198
  # Update stored token
60
199
  token_store.set_token(
61
200
  provider=provider,
@@ -63,21 +202,20 @@ async def _ensure_valid_token(token_store: TokenStore, provider: str) -> str:
63
202
  refresh_token=result.refresh_token or token["refresh_token"],
64
203
  expires_at=time.time() + result.expires_in,
65
204
  )
66
-
205
+
67
206
  return result.access_token
68
207
  except Exception as e:
69
208
  raise ValueError(
70
- f"Token refresh failed: {e}. "
71
- f"Run: python -m mcp_bridge.auth.cli login {provider}"
209
+ f"Token refresh failed: {e}. Run: python -m mcp_bridge.auth.cli login {provider}"
72
210
  )
73
-
211
+
74
212
  access_token = token_store.get_access_token(provider)
75
213
  if not access_token:
76
214
  raise ValueError(
77
215
  f"Not authenticated with {provider}. "
78
216
  f"Run: python -m mcp_bridge.auth.cli login {provider}"
79
217
  )
80
-
218
+
81
219
  return access_token
82
220
 
83
221
 
@@ -87,11 +225,14 @@ def is_retryable_exception(e: Exception) -> bool:
87
225
  return e.response.status_code == 429 or 500 <= e.response.status_code < 600
88
226
  return False
89
227
 
228
+
90
229
  @retry(
91
230
  stop=stop_after_attempt(5),
92
231
  wait=wait_exponential(multiplier=1, min=4, max=60),
93
232
  retry=retry_if_exception(is_retryable_exception),
94
- before_sleep=lambda retry_state: logger.info(f"Rate limited or server error, retrying in {retry_state.next_action.sleep} seconds...")
233
+ before_sleep=lambda retry_state: logger.info(
234
+ f"Rate limited or server error, retrying in {retry_state.next_action.sleep} seconds..."
235
+ ),
95
236
  )
96
237
  async def invoke_gemini(
97
238
  token_store: TokenStore,
@@ -130,7 +271,7 @@ async def invoke_gemini(
130
271
  }
131
272
  hook_manager = get_hook_manager()
132
273
  params = await hook_manager.execute_pre_model_invoke(params)
133
-
274
+
134
275
  # Update local variables from possibly modified params
135
276
  prompt = params["prompt"]
136
277
  model = params["model"]
@@ -140,8 +281,12 @@ async def invoke_gemini(
140
281
 
141
282
  access_token = await _ensure_valid_token(token_store, "gemini")
142
283
 
143
- # Gemini API endpoint with OAuth
144
- api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
284
+ # Resolve user-friendly model name to actual API model ID
285
+ api_model = resolve_gemini_model(model)
286
+
287
+ # Use persistent session ID for thinking signature caching
288
+ session_id = _get_session_id()
289
+ project_id = os.getenv("STRAVINSKY_ANTIGRAVITY_PROJECT_ID", ANTIGRAVITY_DEFAULT_PROJECT_ID)
145
290
 
146
291
  headers = {
147
292
  "Authorization": f"Bearer {access_token}",
@@ -149,58 +294,396 @@ async def invoke_gemini(
149
294
  **ANTIGRAVITY_HEADERS, # Include Antigravity headers
150
295
  }
151
296
 
152
- payload = {
153
- "contents": [{"parts": [{"text": prompt}]}],
297
+ # Build inner request payload
298
+ # Per API spec: contents must include role ("user" or "model")
299
+ inner_payload = {
300
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
154
301
  "generationConfig": {
155
302
  "temperature": temperature,
156
303
  "maxOutputTokens": max_tokens,
157
304
  },
305
+ "sessionId": session_id,
158
306
  }
159
-
307
+
160
308
  # Add thinking budget if supported by model/API
161
309
  if thinking_budget > 0:
162
310
  # For Gemini 2.0+ Thinking models
163
- payload["generationConfig"]["thinkingConfig"] = {
311
+ # Per Antigravity API: use "thinkingBudget", NOT "tokenLimit"
312
+ inner_payload["generationConfig"]["thinkingConfig"] = {
164
313
  "includeThoughts": True,
165
- "tokenLimit": thinking_budget
314
+ "thinkingBudget": thinking_budget,
166
315
  }
167
316
 
168
- async with httpx.AsyncClient() as client:
169
- response = await client.post(
170
- api_url,
171
- headers=headers,
172
- json=payload,
173
- timeout=120.0,
174
- )
175
-
176
- if response.status_code == 401:
177
- raise ValueError(
178
- "Gemini authentication expired. "
179
- "Run: python -m mcp_bridge.auth.cli login gemini"
317
+ # Wrap request body per reference implementation
318
+ wrapped_payload = {
319
+ "project": project_id,
320
+ "model": api_model,
321
+ "userAgent": "antigravity",
322
+ "requestId": f"invoke-{uuid.uuid4()}",
323
+ "request": inner_payload,
324
+ }
325
+
326
+ # Get pooled HTTP client for connection reuse
327
+ client = await _get_http_client()
328
+
329
+ # Try endpoints in fallback order with thinking recovery
330
+ response = None
331
+ last_error = None
332
+ max_retries = 2 # For thinking recovery
333
+
334
+ for retry_attempt in range(max_retries):
335
+ for endpoint in ANTIGRAVITY_ENDPOINTS:
336
+ # Reference uses: {endpoint}/v1internal:generateContent (NOT /models/{model})
337
+ api_url = f"{endpoint}/v1internal:generateContent"
338
+
339
+ try:
340
+ response = await client.post(
341
+ api_url,
342
+ headers=headers,
343
+ json=wrapped_payload,
344
+ timeout=120.0,
345
+ )
346
+
347
+ # 401/403 might be endpoint-specific, try next endpoint
348
+ if response.status_code in (401, 403):
349
+ logger.warning(
350
+ f"[Gemini] Endpoint {endpoint} returned {response.status_code}, trying next"
351
+ )
352
+ last_error = Exception(f"{response.status_code} from {endpoint}")
353
+ continue
354
+
355
+ # Check for thinking-related errors that need recovery
356
+ if response.status_code in (400, 500):
357
+ error_text = response.text.lower()
358
+ if "thinking" in error_text or "signature" in error_text:
359
+ logger.warning(
360
+ f"[Gemini] Thinking error detected, clearing session cache and retrying"
361
+ )
362
+ clear_session_cache()
363
+ # Update session ID for retry
364
+ wrapped_payload["request"]["sessionId"] = _get_session_id()
365
+ last_error = Exception(f"Thinking error: {response.text[:200]}")
366
+ break # Break inner loop to retry with new session
367
+
368
+ # If we got a non-retryable response (success or 4xx client error), use it
369
+ if response.status_code < 500 and response.status_code != 429:
370
+ break
371
+
372
+ except httpx.TimeoutException as e:
373
+ last_error = e
374
+ continue
375
+ except Exception as e:
376
+ last_error = e
377
+ continue
378
+ else:
379
+ # Inner loop completed without break - no thinking recovery needed
380
+ break
381
+
382
+ # If we broke out of inner loop for thinking recovery, continue outer retry loop
383
+ if response and response.status_code in (400, 500):
384
+ continue
385
+ break
386
+
387
+ if response is None:
388
+ raise ValueError(f"All Antigravity endpoints failed: {last_error}")
389
+
390
+ response.raise_for_status()
391
+ data = response.json()
392
+
393
+ # Extract text from response using thinking-aware parser
394
+ return _extract_gemini_response(data)
395
+
396
+
397
+ # ========================
398
+ # AGENTIC FUNCTION CALLING
399
+ # ========================
400
+
401
+ # Tool definitions for background agents
402
+ AGENT_TOOLS = [
403
+ {
404
+ "functionDeclarations": [
405
+ {
406
+ "name": "read_file",
407
+ "description": "Read the contents of a file. Returns the file contents as text.",
408
+ "parameters": {
409
+ "type": "object",
410
+ "properties": {
411
+ "path": {
412
+ "type": "string",
413
+ "description": "Absolute or relative path to the file",
414
+ }
415
+ },
416
+ "required": ["path"],
417
+ },
418
+ },
419
+ {
420
+ "name": "list_directory",
421
+ "description": "List files and directories in a path",
422
+ "parameters": {
423
+ "type": "object",
424
+ "properties": {
425
+ "path": {"type": "string", "description": "Directory path to list"}
426
+ },
427
+ "required": ["path"],
428
+ },
429
+ },
430
+ {
431
+ "name": "grep_search",
432
+ "description": "Search for a pattern in files using ripgrep. Returns matching lines with file paths and line numbers.",
433
+ "parameters": {
434
+ "type": "object",
435
+ "properties": {
436
+ "pattern": {"type": "string", "description": "The search pattern (regex)"},
437
+ "path": {"type": "string", "description": "Directory or file to search in"},
438
+ },
439
+ "required": ["pattern", "path"],
440
+ },
441
+ },
442
+ {
443
+ "name": "write_file",
444
+ "description": "Write content to a file",
445
+ "parameters": {
446
+ "type": "object",
447
+ "properties": {
448
+ "path": {"type": "string", "description": "Path to the file to write"},
449
+ "content": {
450
+ "type": "string",
451
+ "description": "Content to write to the file",
452
+ },
453
+ },
454
+ "required": ["path", "content"],
455
+ },
456
+ },
457
+ ]
458
+ }
459
+ ]
460
+
461
+
462
+ def _execute_tool(name: str, args: dict) -> str:
463
+ """Execute a tool and return the result."""
464
+ import os
465
+ import subprocess
466
+ from pathlib import Path
467
+
468
+ try:
469
+ if name == "read_file":
470
+ path = Path(args["path"])
471
+ if not path.exists():
472
+ return f"Error: File not found: {path}"
473
+ return path.read_text()
474
+
475
+ elif name == "list_directory":
476
+ path = Path(args["path"])
477
+ if not path.exists():
478
+ return f"Error: Directory not found: {path}"
479
+ entries = []
480
+ for entry in path.iterdir():
481
+ entry_type = "DIR" if entry.is_dir() else "FILE"
482
+ entries.append(f"[{entry_type}] {entry.name}")
483
+ return "\n".join(entries) if entries else "(empty directory)"
484
+
485
+ elif name == "grep_search":
486
+ pattern = args["pattern"]
487
+ search_path = args["path"]
488
+ result = subprocess.run(
489
+ ["rg", "--json", "-m", "50", pattern, search_path],
490
+ capture_output=True,
491
+ text=True,
492
+ timeout=30,
180
493
  )
181
-
182
- response.raise_for_status()
494
+ if result.returncode == 0:
495
+ return result.stdout[:10000] # Limit output size
496
+ elif result.returncode == 1:
497
+ return "No matches found"
498
+ else:
499
+ return f"Search error: {result.stderr}"
500
+
501
+ elif name == "write_file":
502
+ path = Path(args["path"])
503
+ path.parent.mkdir(parents=True, exist_ok=True)
504
+ path.write_text(args["content"])
505
+ return f"Successfully wrote {len(args['content'])} bytes to {path}"
506
+
507
+ else:
508
+ return f"Unknown tool: {name}"
183
509
 
510
+ except Exception as e:
511
+ return f"Tool error: {str(e)}"
512
+
513
+
514
+ async def invoke_gemini_agentic(
515
+ token_store: TokenStore,
516
+ prompt: str,
517
+ model: str = "gemini-3-flash",
518
+ max_turns: int = 10,
519
+ timeout: int = 120,
520
+ ) -> str:
521
+ """
522
+ Invoke Gemini with function calling for agentic tasks.
523
+
524
+ This function implements a multi-turn agentic loop:
525
+ 1. Send prompt with tool definitions
526
+ 2. If model returns FunctionCall, execute the tool
527
+ 3. Send FunctionResponse back to model
528
+ 4. Repeat until model returns text or max_turns reached
529
+
530
+ Args:
531
+ token_store: Token store for OAuth credentials
532
+ prompt: The task prompt
533
+ model: Gemini model to use
534
+ max_turns: Maximum number of tool-use turns
535
+ timeout: Request timeout in seconds
536
+
537
+ Returns:
538
+ Final text response from the model
539
+ """
540
+ import uuid
541
+
542
+ access_token = await _ensure_valid_token(token_store, "gemini")
543
+ api_model = resolve_gemini_model(model)
544
+
545
+ # Use persistent session ID for this conversation
546
+ session_id = _get_session_id(conversation_key="agentic")
547
+
548
+ # Project ID from environment or default
549
+ project_id = os.getenv("STRAVINSKY_ANTIGRAVITY_PROJECT_ID", ANTIGRAVITY_DEFAULT_PROJECT_ID)
550
+
551
+ headers = {
552
+ "Authorization": f"Bearer {access_token}",
553
+ "Content-Type": "application/json",
554
+ **ANTIGRAVITY_HEADERS,
555
+ }
556
+
557
+ # Initialize conversation
558
+ contents = [{"role": "user", "parts": [{"text": prompt}]}]
559
+
560
+ # Get pooled HTTP client for connection reuse
561
+ client = await _get_http_client()
562
+
563
+ for turn in range(max_turns):
564
+ # Build inner request payload (what goes inside "request" wrapper)
565
+ inner_payload = {
566
+ "contents": contents,
567
+ "tools": AGENT_TOOLS,
568
+ "generationConfig": {
569
+ "temperature": 0.7,
570
+ "maxOutputTokens": 8192,
571
+ },
572
+ "sessionId": session_id,
573
+ }
574
+
575
+ # Wrap request body per reference implementation
576
+ # From request.ts wrapRequestBody()
577
+ wrapped_payload = {
578
+ "project": project_id,
579
+ "model": api_model,
580
+ "userAgent": "antigravity",
581
+ "requestId": f"agent-{uuid.uuid4()}",
582
+ "request": inner_payload,
583
+ }
584
+
585
+ # Try endpoints in fallback order
586
+ response = None
587
+ last_error = None
588
+
589
+ for endpoint in ANTIGRAVITY_ENDPOINTS:
590
+ # Reference uses: {endpoint}/v1internal:generateContent (NOT /models/{model})
591
+ api_url = f"{endpoint}/v1internal:generateContent"
592
+
593
+ try:
594
+ response = await client.post(
595
+ api_url,
596
+ headers=headers,
597
+ json=wrapped_payload,
598
+ timeout=float(timeout),
599
+ )
600
+
601
+ # 401/403 might be endpoint-specific, try next endpoint
602
+ if response.status_code in (401, 403):
603
+ logger.warning(
604
+ f"[AgenticGemini] Endpoint {endpoint} returned {response.status_code}, trying next"
605
+ )
606
+ last_error = Exception(f"{response.status_code} from {endpoint}")
607
+ continue
608
+
609
+ # If we got a non-retryable response (success or 4xx client error), use it
610
+ if response.status_code < 500 and response.status_code != 429:
611
+ break
612
+
613
+ logger.warning(
614
+ f"[AgenticGemini] Endpoint {endpoint} returned {response.status_code}, trying next"
615
+ )
616
+
617
+ except httpx.TimeoutException as e:
618
+ last_error = e
619
+ logger.warning(f"[AgenticGemini] Endpoint {endpoint} timed out, trying next")
620
+ continue
621
+ except Exception as e:
622
+ last_error = e
623
+ logger.warning(f"[AgenticGemini] Endpoint {endpoint} failed: {e}, trying next")
624
+ continue
625
+
626
+ if response is None:
627
+ raise ValueError(f"All Antigravity endpoints failed: {last_error}")
628
+
629
+ response.raise_for_status()
184
630
  data = response.json()
185
631
 
186
- # Extract text from response
187
- try:
188
- candidates = data.get("candidates", [])
189
- if candidates:
190
- content = candidates[0].get("content", {})
191
- parts = content.get("parts", [])
192
- if parts:
193
- return parts[0].get("text", "")
632
+ # Extract response - unwrap outer "response" envelope if present
633
+ inner_response = data.get("response", data)
634
+ candidates = inner_response.get("candidates", [])
635
+ if not candidates:
194
636
  return "No response generated"
195
- except (KeyError, IndexError) as e:
196
- return f"Error parsing response: {e}"
637
+
638
+ content = candidates[0].get("content", {})
639
+ parts = content.get("parts", [])
640
+
641
+ if not parts:
642
+ return "No response parts"
643
+
644
+ # Check for function call
645
+ function_call = None
646
+ text_response = None
647
+
648
+ for part in parts:
649
+ if "functionCall" in part:
650
+ function_call = part["functionCall"]
651
+ break
652
+ elif "text" in part:
653
+ text_response = part["text"]
654
+
655
+ if function_call:
656
+ # Execute the function
657
+ func_name = function_call.get("name")
658
+ func_args = function_call.get("args", {})
659
+
660
+ logger.info(f"[AgenticGemini] Turn {turn + 1}: Executing {func_name}")
661
+ result = _execute_tool(func_name, func_args)
662
+
663
+ # Add model's response and function result to conversation
664
+ contents.append({"role": "model", "parts": [{"functionCall": function_call}]})
665
+ contents.append(
666
+ {
667
+ "role": "user",
668
+ "parts": [
669
+ {"functionResponse": {"name": func_name, "response": {"result": result}}}
670
+ ],
671
+ }
672
+ )
673
+ else:
674
+ # No function call, return text response
675
+ return text_response or "Task completed"
676
+
677
+ return "Max turns reached without final response"
197
678
 
198
679
 
199
680
  @retry(
200
681
  stop=stop_after_attempt(5),
201
682
  wait=wait_exponential(multiplier=1, min=4, max=60),
202
683
  retry=retry_if_exception(is_retryable_exception),
203
- before_sleep=lambda retry_state: logger.info(f"Rate limited or server error, retrying in {retry_state.next_action.sleep} seconds...")
684
+ before_sleep=lambda retry_state: logger.info(
685
+ f"Rate limited or server error, retrying in {retry_state.next_action.sleep} seconds..."
686
+ ),
204
687
  )
205
688
  async def invoke_openai(
206
689
  token_store: TokenStore,
@@ -237,7 +720,7 @@ async def invoke_openai(
237
720
  }
238
721
  hook_manager = get_hook_manager()
239
722
  params = await hook_manager.execute_pre_model_invoke(params)
240
-
723
+
241
724
  # Update local variables from possibly modified params
242
725
  prompt = params["prompt"]
243
726
  model = params["model"]
@@ -260,7 +743,7 @@ async def invoke_openai(
260
743
  "messages": [{"role": "user", "content": prompt}],
261
744
  "temperature": temperature,
262
745
  }
263
-
746
+
264
747
  # Handle thinking budget for O1/O3 style models (GPT-5.2)
265
748
  if thinking_budget > 0:
266
749
  payload["max_completion_tokens"] = max_tokens + thinking_budget
@@ -275,13 +758,12 @@ async def invoke_openai(
275
758
  json=payload,
276
759
  timeout=120.0,
277
760
  )
278
-
761
+
279
762
  if response.status_code == 401:
280
763
  raise ValueError(
281
- "OpenAI authentication failed. "
282
- "Run: python -m mcp_bridge.auth.cli login openai"
764
+ "OpenAI authentication failed. Run: python -m mcp_bridge.auth.cli login openai"
283
765
  )
284
-
766
+
285
767
  response.raise_for_status()
286
768
 
287
769
  data = response.json()