code-puppy 0.0.361__py3-none-any.whl → 0.0.372__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.
- code_puppy/agents/__init__.py +6 -0
- code_puppy/agents/agent_manager.py +223 -1
- code_puppy/agents/base_agent.py +2 -12
- code_puppy/chatgpt_codex_client.py +53 -0
- code_puppy/claude_cache_client.py +45 -7
- code_puppy/command_line/add_model_menu.py +13 -4
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/core_commands.py +4 -112
- code_puppy/command_line/model_picker_completion.py +3 -20
- code_puppy/command_line/model_settings_menu.py +21 -3
- code_puppy/config.py +79 -8
- code_puppy/gemini_model.py +706 -0
- code_puppy/http_utils.py +6 -3
- code_puppy/model_factory.py +50 -16
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +1 -52
- code_puppy/models.json +12 -12
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +128 -165
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
- code_puppy/plugins/antigravity_oauth/transport.py +235 -45
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
- code_puppy/plugins/claude_code_oauth/utils.py +4 -1
- code_puppy/pydantic_patches.py +52 -0
- code_puppy/tools/agent_tools.py +3 -3
- code_puppy/tools/browser/__init__.py +1 -1
- code_puppy/tools/browser/browser_control.py +1 -1
- code_puppy/tools/browser/browser_interactions.py +1 -1
- code_puppy/tools/browser/browser_locators.py +1 -1
- code_puppy/tools/browser/{camoufox_manager.py → browser_manager.py} +29 -110
- code_puppy/tools/browser/browser_navigation.py +1 -1
- code_puppy/tools/browser/browser_screenshot.py +1 -1
- code_puppy/tools/browser/browser_scripts.py +1 -1
- {code_puppy-0.0.361.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
- {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/METADATA +5 -6
- {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/RECORD +40 -38
- code_puppy/prompts/codex_system_prompt.md +0 -310
- {code_puppy-0.0.361.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
|
@@ -23,20 +23,78 @@ from .constants import (
|
|
|
23
23
|
logger = logging.getLogger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
def _flatten_union_to_object(union_items: list, defs: dict, resolve_fn) -> dict:
|
|
27
|
+
"""Flatten a union of object types into a single object with all properties.
|
|
28
|
+
|
|
29
|
+
For discriminated unions like EditFilePayload (ContentPayload | ReplacementsPayload | DeleteSnippetPayload),
|
|
30
|
+
we merge all object types into one with all properties marked as optional.
|
|
31
|
+
"""
|
|
32
|
+
merged_properties = {}
|
|
33
|
+
has_string_type = False
|
|
34
|
+
|
|
35
|
+
for item in union_items:
|
|
36
|
+
if not isinstance(item, dict):
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
# Resolve $ref first
|
|
40
|
+
if "$ref" in item:
|
|
41
|
+
ref_path = item["$ref"]
|
|
42
|
+
ref_name = None
|
|
43
|
+
if ref_path.startswith("#/$defs/"):
|
|
44
|
+
ref_name = ref_path[8:]
|
|
45
|
+
elif ref_path.startswith("#/definitions/"):
|
|
46
|
+
ref_name = ref_path[14:]
|
|
47
|
+
if ref_name and ref_name in defs:
|
|
48
|
+
item = copy.deepcopy(defs[ref_name])
|
|
49
|
+
else:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
# Check for string type (common fallback)
|
|
53
|
+
if item.get("type") == "string":
|
|
54
|
+
has_string_type = True
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Skip null types
|
|
58
|
+
if item.get("type") == "null":
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Merge properties from object types
|
|
62
|
+
if item.get("type") == "object" or "properties" in item:
|
|
63
|
+
props = item.get("properties", {})
|
|
64
|
+
for prop_name, prop_schema in props.items():
|
|
65
|
+
if prop_name not in merged_properties:
|
|
66
|
+
# Resolve the property schema
|
|
67
|
+
merged_properties[prop_name] = resolve_fn(
|
|
68
|
+
copy.deepcopy(prop_schema)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not merged_properties:
|
|
72
|
+
# No object properties found, return string type as fallback
|
|
73
|
+
return {"type": "string"} if has_string_type else {"type": "object"}
|
|
74
|
+
|
|
75
|
+
# Build merged object - no required fields since any subset is valid
|
|
76
|
+
result = {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": merged_properties,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _inline_refs(schema: dict, simplify_unions: bool = False) -> dict:
|
|
29
85
|
"""Inline $ref references and transform schema for Antigravity compatibility.
|
|
30
86
|
|
|
31
87
|
- Inlines $ref references
|
|
32
88
|
- Removes $defs, definitions, $schema, $id
|
|
33
|
-
- Optionally converts anyOf/oneOf/allOf to any_of/one_of/all_of (only for Gemini)
|
|
34
89
|
- Removes unsupported fields like 'default', 'examples', 'const'
|
|
35
|
-
-
|
|
90
|
+
- When simplify_unions=True: flattens anyOf/oneOf unions:
|
|
91
|
+
- For unions of objects: merges into single object with all properties
|
|
92
|
+
- For simple unions (string | null): picks first non-null type
|
|
93
|
+
(required for both Gemini AND Claude - neither supports union types in function schemas!)
|
|
36
94
|
|
|
37
95
|
Args:
|
|
38
|
-
|
|
39
|
-
|
|
96
|
+
simplify_unions: If True, simplify anyOf/oneOf unions.
|
|
97
|
+
Required for Gemini and Claude models.
|
|
40
98
|
"""
|
|
41
99
|
if not isinstance(schema, dict):
|
|
42
100
|
return schema
|
|
@@ -47,31 +105,73 @@ def _inline_refs(
|
|
|
47
105
|
# Extract $defs for reference resolution
|
|
48
106
|
defs = schema.pop("$defs", schema.pop("definitions", {}))
|
|
49
107
|
|
|
50
|
-
def resolve_refs(
|
|
51
|
-
obj, convert_unions=convert_unions, simplify_for_claude=simplify_for_claude
|
|
52
|
-
):
|
|
108
|
+
def resolve_refs(obj, simplify_unions=simplify_unions):
|
|
53
109
|
"""Recursively resolve $ref references and transform schema."""
|
|
54
110
|
if isinstance(obj, dict):
|
|
55
|
-
#
|
|
56
|
-
if
|
|
111
|
+
# Handle anyOf/oneOf unions
|
|
112
|
+
if simplify_unions:
|
|
57
113
|
for union_key in ["anyOf", "oneOf"]:
|
|
58
114
|
if union_key in obj:
|
|
59
115
|
union = obj[union_key]
|
|
60
116
|
if isinstance(union, list):
|
|
61
|
-
#
|
|
117
|
+
# Check if this is a complex union of objects (discriminated union)
|
|
118
|
+
# vs a simple nullable type (string | null)
|
|
119
|
+
object_count = 0
|
|
120
|
+
has_refs = False
|
|
121
|
+
for item in union:
|
|
122
|
+
if isinstance(item, dict):
|
|
123
|
+
if "$ref" in item:
|
|
124
|
+
has_refs = True
|
|
125
|
+
object_count += 1
|
|
126
|
+
elif (
|
|
127
|
+
item.get("type") == "object"
|
|
128
|
+
or "properties" in item
|
|
129
|
+
):
|
|
130
|
+
object_count += 1
|
|
131
|
+
|
|
132
|
+
# If multiple objects or has refs, flatten to single object
|
|
133
|
+
if object_count > 1 or has_refs:
|
|
134
|
+
flattened = _flatten_union_to_object(
|
|
135
|
+
union, defs, resolve_refs
|
|
136
|
+
)
|
|
137
|
+
# Keep description if present
|
|
138
|
+
if "description" in obj:
|
|
139
|
+
flattened["description"] = obj["description"]
|
|
140
|
+
return flattened
|
|
141
|
+
|
|
142
|
+
# Simple union - pick first non-null type
|
|
62
143
|
for item in union:
|
|
63
144
|
if (
|
|
64
145
|
isinstance(item, dict)
|
|
65
146
|
and item.get("type") != "null"
|
|
66
147
|
):
|
|
67
|
-
# Replace the whole object with this type
|
|
68
148
|
result = dict(item)
|
|
69
|
-
# Keep description if present
|
|
70
149
|
if "description" in obj:
|
|
71
150
|
result["description"] = obj["description"]
|
|
72
|
-
return resolve_refs(
|
|
73
|
-
|
|
74
|
-
|
|
151
|
+
return resolve_refs(result, simplify_unions)
|
|
152
|
+
|
|
153
|
+
# Also handle allOf by merging all schemas
|
|
154
|
+
if simplify_unions and "allOf" in obj:
|
|
155
|
+
all_of = obj["allOf"]
|
|
156
|
+
if isinstance(all_of, list):
|
|
157
|
+
merged = {}
|
|
158
|
+
merged_properties = {}
|
|
159
|
+
for item in all_of:
|
|
160
|
+
if isinstance(item, dict):
|
|
161
|
+
resolved_item = resolve_refs(item, simplify_unions)
|
|
162
|
+
# Deep merge properties dicts
|
|
163
|
+
if "properties" in resolved_item:
|
|
164
|
+
merged_properties.update(
|
|
165
|
+
resolved_item.pop("properties")
|
|
166
|
+
)
|
|
167
|
+
merged.update(resolved_item)
|
|
168
|
+
if merged_properties:
|
|
169
|
+
merged["properties"] = merged_properties
|
|
170
|
+
# Keep other fields from original object (except allOf)
|
|
171
|
+
for k, v in obj.items():
|
|
172
|
+
if k != "allOf":
|
|
173
|
+
merged[k] = v
|
|
174
|
+
return resolve_refs(merged, simplify_unions)
|
|
75
175
|
|
|
76
176
|
# Check for $ref
|
|
77
177
|
if "$ref" in obj:
|
|
@@ -111,34 +211,23 @@ def _inline_refs(
|
|
|
111
211
|
):
|
|
112
212
|
continue
|
|
113
213
|
|
|
114
|
-
#
|
|
115
|
-
if
|
|
214
|
+
# Skip additionalProperties (not supported by Gemini or Claude)
|
|
215
|
+
if simplify_unions and key == "additionalProperties":
|
|
116
216
|
continue
|
|
117
217
|
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
if
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
new_key = "one_of"
|
|
125
|
-
elif key == "allOf":
|
|
126
|
-
new_key = "all_of"
|
|
127
|
-
elif key == "additionalProperties":
|
|
128
|
-
new_key = "additional_properties"
|
|
129
|
-
|
|
130
|
-
result[new_key] = resolve_refs(
|
|
131
|
-
value, convert_unions, simplify_for_claude
|
|
132
|
-
)
|
|
218
|
+
# Skip any remaining union type keys that weren't simplified above
|
|
219
|
+
# (This shouldn't happen normally, but just in case)
|
|
220
|
+
if simplify_unions and key in ("anyOf", "oneOf", "allOf"):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
result[key] = resolve_refs(value, simplify_unions)
|
|
133
224
|
return result
|
|
134
225
|
elif isinstance(obj, list):
|
|
135
|
-
return [
|
|
136
|
-
resolve_refs(item, convert_unions, simplify_for_claude) for item in obj
|
|
137
|
-
]
|
|
226
|
+
return [resolve_refs(item, simplify_unions) for item in obj]
|
|
138
227
|
else:
|
|
139
228
|
return obj
|
|
140
229
|
|
|
141
|
-
return resolve_refs(schema,
|
|
230
|
+
return resolve_refs(schema, simplify_unions)
|
|
142
231
|
|
|
143
232
|
|
|
144
233
|
class UnwrappedResponse(httpx.Response):
|
|
@@ -264,17 +353,91 @@ class UnwrappedSSEResponse(httpx.Response):
|
|
|
264
353
|
|
|
265
354
|
|
|
266
355
|
class AntigravityClient(httpx.AsyncClient):
|
|
267
|
-
"""Custom httpx client that handles Antigravity request/response wrapping.
|
|
356
|
+
"""Custom httpx client that handles Antigravity request/response wrapping.
|
|
357
|
+
|
|
358
|
+
Supports proactive token refresh to prevent expiry during long sessions.
|
|
359
|
+
"""
|
|
268
360
|
|
|
269
361
|
def __init__(
|
|
270
362
|
self,
|
|
271
363
|
project_id: str = "",
|
|
272
364
|
model_name: str = "",
|
|
365
|
+
refresh_token: str = "",
|
|
366
|
+
expires_at: Optional[float] = None,
|
|
367
|
+
on_token_refreshed: Optional[Any] = None,
|
|
273
368
|
**kwargs: Any,
|
|
274
369
|
):
|
|
275
370
|
super().__init__(**kwargs)
|
|
276
371
|
self.project_id = project_id
|
|
277
372
|
self.model_name = model_name
|
|
373
|
+
self._refresh_token = refresh_token
|
|
374
|
+
self._expires_at = expires_at
|
|
375
|
+
self._on_token_refreshed = on_token_refreshed
|
|
376
|
+
self._refresh_lock = None # Lazy init for async lock
|
|
377
|
+
|
|
378
|
+
async def _ensure_valid_token(self) -> None:
|
|
379
|
+
"""Proactively refresh the access token if it's expired or about to expire.
|
|
380
|
+
|
|
381
|
+
This prevents 401 errors during long-running sessions by checking and
|
|
382
|
+
refreshing the token BEFORE making requests, not after they fail.
|
|
383
|
+
"""
|
|
384
|
+
import asyncio
|
|
385
|
+
|
|
386
|
+
from .token import is_token_expired, refresh_access_token
|
|
387
|
+
|
|
388
|
+
# Skip if no refresh token configured
|
|
389
|
+
if not self._refresh_token:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
# Check if token needs refresh (includes 60-second buffer)
|
|
393
|
+
if not is_token_expired(self._expires_at):
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# Lazy init the async lock
|
|
397
|
+
if self._refresh_lock is None:
|
|
398
|
+
self._refresh_lock = asyncio.Lock()
|
|
399
|
+
|
|
400
|
+
async with self._refresh_lock:
|
|
401
|
+
# Double-check after acquiring lock (another coroutine may have refreshed)
|
|
402
|
+
if not is_token_expired(self._expires_at):
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
logger.debug("Proactively refreshing Antigravity access token...")
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
# Run the synchronous refresh in a thread pool to avoid blocking
|
|
409
|
+
loop = asyncio.get_event_loop()
|
|
410
|
+
new_tokens = await loop.run_in_executor(
|
|
411
|
+
None, refresh_access_token, self._refresh_token
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if new_tokens:
|
|
415
|
+
# Update internal state
|
|
416
|
+
new_access_token = new_tokens.access_token
|
|
417
|
+
self._expires_at = new_tokens.expires_at
|
|
418
|
+
self._refresh_token = new_tokens.refresh_token
|
|
419
|
+
|
|
420
|
+
# Update the Authorization header
|
|
421
|
+
self.headers["Authorization"] = f"Bearer {new_access_token}"
|
|
422
|
+
|
|
423
|
+
logger.info(
|
|
424
|
+
"Proactively refreshed Antigravity token (expires in %ds)",
|
|
425
|
+
int(self._expires_at - __import__("time").time()),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Notify callback (e.g., to persist updated tokens)
|
|
429
|
+
if self._on_token_refreshed:
|
|
430
|
+
try:
|
|
431
|
+
self._on_token_refreshed(new_tokens)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.warning("Token refresh callback failed: %s", e)
|
|
434
|
+
else:
|
|
435
|
+
logger.warning(
|
|
436
|
+
"Failed to proactively refresh token - request may fail with 401"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
except Exception as e:
|
|
440
|
+
logger.warning("Proactive token refresh error: %s", e)
|
|
278
441
|
|
|
279
442
|
def _wrap_request(self, content: bytes, url: str) -> tuple[bytes, str, str, bool]:
|
|
280
443
|
"""Wrap request body in Antigravity envelope and transform URL.
|
|
@@ -331,15 +494,14 @@ class AntigravityClient(httpx.AsyncClient):
|
|
|
331
494
|
)
|
|
332
495
|
|
|
333
496
|
# Inline $refs and remove $defs from parameters
|
|
334
|
-
#
|
|
335
|
-
#
|
|
497
|
+
# Simplify union types (anyOf/oneOf/allOf) for BOTH Gemini and Claude
|
|
498
|
+
# Neither API supports union types in function schemas!
|
|
336
499
|
if "parameters" in func_decl:
|
|
337
500
|
is_gemini = "gemini" in model.lower()
|
|
338
501
|
is_claude = "claude" in model.lower()
|
|
339
502
|
func_decl["parameters"] = _inline_refs(
|
|
340
503
|
func_decl["parameters"],
|
|
341
|
-
|
|
342
|
-
simplify_for_claude=is_claude,
|
|
504
|
+
simplify_unions=(is_gemini or is_claude),
|
|
343
505
|
)
|
|
344
506
|
|
|
345
507
|
# Fix generationConfig for Antigravity compatibility
|
|
@@ -425,6 +587,9 @@ class AntigravityClient(httpx.AsyncClient):
|
|
|
425
587
|
"""Override send to intercept at the lowest level with endpoint fallback."""
|
|
426
588
|
import asyncio
|
|
427
589
|
|
|
590
|
+
# Proactively refresh token BEFORE making the request
|
|
591
|
+
await self._ensure_valid_token()
|
|
592
|
+
|
|
428
593
|
# Transform POST requests to Antigravity format
|
|
429
594
|
if request.method == "POST" and request.content:
|
|
430
595
|
new_content, new_path, new_query, is_claude_thinking = self._wrap_request(
|
|
@@ -638,14 +803,36 @@ class AntigravityClient(httpx.AsyncClient):
|
|
|
638
803
|
return None
|
|
639
804
|
|
|
640
805
|
|
|
806
|
+
# Type alias for token refresh callback
|
|
807
|
+
TokenRefreshCallback = Any # Callable[[OAuthTokens], None]
|
|
808
|
+
|
|
809
|
+
|
|
641
810
|
def create_antigravity_client(
|
|
642
811
|
access_token: str,
|
|
643
812
|
project_id: str = "",
|
|
644
813
|
model_name: str = "",
|
|
645
814
|
base_url: str = "https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
646
815
|
headers: Optional[Dict[str, str]] = None,
|
|
816
|
+
refresh_token: str = "",
|
|
817
|
+
expires_at: Optional[float] = None,
|
|
818
|
+
on_token_refreshed: Optional[TokenRefreshCallback] = None,
|
|
647
819
|
) -> AntigravityClient:
|
|
648
|
-
"""Create an httpx client configured for Antigravity API.
|
|
820
|
+
"""Create an httpx client configured for Antigravity API.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
access_token: The OAuth access token for authentication
|
|
824
|
+
project_id: The GCP project ID
|
|
825
|
+
model_name: The model name being used
|
|
826
|
+
base_url: The API base URL
|
|
827
|
+
headers: Additional headers to include
|
|
828
|
+
refresh_token: The OAuth refresh token for proactive token refresh
|
|
829
|
+
expires_at: Unix timestamp when the access token expires
|
|
830
|
+
on_token_refreshed: Callback called when token is proactively refreshed,
|
|
831
|
+
receives OAuthTokens object to persist the new tokens
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
An AntigravityClient configured for API requests with proactive token refresh
|
|
835
|
+
"""
|
|
649
836
|
# Start with Antigravity-specific headers
|
|
650
837
|
default_headers = {
|
|
651
838
|
"Authorization": f"Bearer {access_token}",
|
|
@@ -659,6 +846,9 @@ def create_antigravity_client(
|
|
|
659
846
|
return AntigravityClient(
|
|
660
847
|
project_id=project_id,
|
|
661
848
|
model_name=model_name,
|
|
849
|
+
refresh_token=refresh_token,
|
|
850
|
+
expires_at=expires_at,
|
|
851
|
+
on_token_refreshed=on_token_refreshed,
|
|
662
852
|
base_url=base_url,
|
|
663
853
|
headers=default_headers,
|
|
664
854
|
timeout=httpx.Timeout(180.0, connect=30.0),
|
|
@@ -6,8 +6,8 @@ import os
|
|
|
6
6
|
from typing import List, Optional, Tuple
|
|
7
7
|
|
|
8
8
|
from code_puppy.callbacks import register_callback
|
|
9
|
-
from code_puppy.config import set_model_name
|
|
10
9
|
from code_puppy.messaging import emit_info, emit_success, emit_warning
|
|
10
|
+
from code_puppy.model_switching import set_model_and_reload_agent
|
|
11
11
|
|
|
12
12
|
from .config import CHATGPT_OAUTH_CONFIG, get_token_storage_path
|
|
13
13
|
from .oauth_flow import run_oauth_flow
|
|
@@ -76,7 +76,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
|
|
|
76
76
|
|
|
77
77
|
if name == "chatgpt-auth":
|
|
78
78
|
run_oauth_flow()
|
|
79
|
-
|
|
79
|
+
set_model_and_reload_agent("chatgpt-gpt-5.2-codex")
|
|
80
80
|
return True
|
|
81
81
|
|
|
82
82
|
if name == "chatgpt-status":
|
|
@@ -12,8 +12,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
12
12
|
from urllib.parse import parse_qs, urlparse
|
|
13
13
|
|
|
14
14
|
from code_puppy.callbacks import register_callback
|
|
15
|
-
from code_puppy.config import set_model_name
|
|
16
15
|
from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
|
|
16
|
+
from code_puppy.model_switching import set_model_and_reload_agent
|
|
17
17
|
|
|
18
18
|
from ..oauth_puppy_html import oauth_failure_html, oauth_success_html
|
|
19
19
|
from .config import CLAUDE_CODE_OAUTH_CONFIG, get_token_storage_path
|
|
@@ -181,31 +181,6 @@ def _custom_help() -> List[Tuple[str, str]]:
|
|
|
181
181
|
]
|
|
182
182
|
|
|
183
183
|
|
|
184
|
-
def _reload_current_agent() -> None:
|
|
185
|
-
"""Reload the current agent so new auth tokens are picked up immediately."""
|
|
186
|
-
try:
|
|
187
|
-
from code_puppy.agents import get_current_agent
|
|
188
|
-
|
|
189
|
-
current_agent = get_current_agent()
|
|
190
|
-
if current_agent is None:
|
|
191
|
-
logger.debug("No current agent to reload")
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
# JSON agents may need to refresh their config before reload
|
|
195
|
-
if hasattr(current_agent, "refresh_config"):
|
|
196
|
-
try:
|
|
197
|
-
current_agent.refresh_config()
|
|
198
|
-
except Exception:
|
|
199
|
-
# Non-fatal, continue to reload
|
|
200
|
-
pass
|
|
201
|
-
|
|
202
|
-
current_agent.reload_code_generation_agent()
|
|
203
|
-
emit_info("Active agent reloaded with new authentication")
|
|
204
|
-
except Exception as e:
|
|
205
|
-
emit_warning(f"Authentication succeeded but agent reload failed: {e}")
|
|
206
|
-
logger.exception("Failed to reload agent after authentication")
|
|
207
|
-
|
|
208
|
-
|
|
209
184
|
def _perform_authentication() -> None:
|
|
210
185
|
context = prepare_oauth_context()
|
|
211
186
|
code = _await_callback(context)
|
|
@@ -245,9 +220,6 @@ def _perform_authentication() -> None:
|
|
|
245
220
|
"Claude Code models added to your configuration. Use the `claude-code-` prefix!"
|
|
246
221
|
)
|
|
247
222
|
|
|
248
|
-
# Reload the current agent so the new auth token is picked up immediately
|
|
249
|
-
_reload_current_agent()
|
|
250
|
-
|
|
251
223
|
|
|
252
224
|
def _handle_custom_command(command: str, name: str) -> Optional[bool]:
|
|
253
225
|
if not name:
|
|
@@ -261,7 +233,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
|
|
|
261
233
|
"Existing Claude Code tokens found. Continuing will overwrite them."
|
|
262
234
|
)
|
|
263
235
|
_perform_authentication()
|
|
264
|
-
|
|
236
|
+
set_model_and_reload_agent("claude-code-claude-opus-4-5-20251101")
|
|
265
237
|
return True
|
|
266
238
|
|
|
267
239
|
if name == "claude-code-status":
|
|
@@ -21,7 +21,10 @@ from .config import (
|
|
|
21
21
|
get_token_storage_path,
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# Proactive refresh buffer: refresh tokens 1 hour before expiration
|
|
25
|
+
# This ensures smooth operation by refreshing during model requests,
|
|
26
|
+
# well before the token actually expires
|
|
27
|
+
TOKEN_REFRESH_BUFFER_SECONDS = 3600 # 1 hour
|
|
25
28
|
|
|
26
29
|
logger = logging.getLogger(__name__)
|
|
27
30
|
|
code_puppy/pydantic_patches.py
CHANGED
|
@@ -121,6 +121,57 @@ def patch_process_message_history() -> None:
|
|
|
121
121
|
pass
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def patch_tool_call_json_repair() -> None:
|
|
125
|
+
"""Patch pydantic-ai's _call_tool to auto-repair malformed JSON arguments.
|
|
126
|
+
|
|
127
|
+
LLMs sometimes produce slightly broken JSON in tool calls (trailing commas,
|
|
128
|
+
missing quotes, etc.). This patch intercepts tool calls and runs json_repair
|
|
129
|
+
on the arguments before validation, preventing unnecessary retries.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
import json_repair
|
|
133
|
+
from pydantic_ai._tool_manager import ToolManager
|
|
134
|
+
|
|
135
|
+
# Store the original method
|
|
136
|
+
_original_call_tool = ToolManager._call_tool
|
|
137
|
+
|
|
138
|
+
async def _patched_call_tool(
|
|
139
|
+
self,
|
|
140
|
+
call,
|
|
141
|
+
*,
|
|
142
|
+
allow_partial: bool,
|
|
143
|
+
wrap_validation_errors: bool,
|
|
144
|
+
approved: bool,
|
|
145
|
+
):
|
|
146
|
+
"""Patched _call_tool that repairs malformed JSON before validation."""
|
|
147
|
+
# Only attempt repair if args is a string (JSON)
|
|
148
|
+
if isinstance(call.args, str) and call.args:
|
|
149
|
+
try:
|
|
150
|
+
repaired = json_repair.repair_json(call.args)
|
|
151
|
+
if repaired != call.args:
|
|
152
|
+
# Update the call args with repaired JSON
|
|
153
|
+
call.args = repaired
|
|
154
|
+
except Exception:
|
|
155
|
+
pass # If repair fails, let original validation handle it
|
|
156
|
+
|
|
157
|
+
# Call the original method
|
|
158
|
+
return await _original_call_tool(
|
|
159
|
+
self,
|
|
160
|
+
call,
|
|
161
|
+
allow_partial=allow_partial,
|
|
162
|
+
wrap_validation_errors=wrap_validation_errors,
|
|
163
|
+
approved=approved,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Apply the patch
|
|
167
|
+
ToolManager._call_tool = _patched_call_tool
|
|
168
|
+
|
|
169
|
+
except ImportError:
|
|
170
|
+
pass # json_repair or pydantic_ai not available
|
|
171
|
+
except Exception:
|
|
172
|
+
pass # Don't crash on patch failure
|
|
173
|
+
|
|
174
|
+
|
|
124
175
|
def apply_all_patches() -> None:
|
|
125
176
|
"""Apply all pydantic-ai monkey patches.
|
|
126
177
|
|
|
@@ -129,3 +180,4 @@ def apply_all_patches() -> None:
|
|
|
129
180
|
patch_user_agent()
|
|
130
181
|
patch_message_history_cleaning()
|
|
131
182
|
patch_process_message_history()
|
|
183
|
+
patch_tool_call_json_repair()
|
code_puppy/tools/agent_tools.py
CHANGED
|
@@ -443,9 +443,9 @@ def register_invoke_agent(agent):
|
|
|
443
443
|
|
|
444
444
|
terminal_session_token = set_terminal_session(f"terminal-{session_id}")
|
|
445
445
|
|
|
446
|
-
# Set browser session for
|
|
446
|
+
# Set browser session for browser tools (qa-kitten, etc.)
|
|
447
447
|
# This allows parallel agent invocations to each have their own browser
|
|
448
|
-
from code_puppy.tools.browser.
|
|
448
|
+
from code_puppy.tools.browser.browser_manager import (
|
|
449
449
|
set_browser_session,
|
|
450
450
|
)
|
|
451
451
|
|
|
@@ -665,7 +665,7 @@ def register_invoke_agent(agent):
|
|
|
665
665
|
# Reset terminal session context
|
|
666
666
|
_terminal_session_var.reset(terminal_session_token)
|
|
667
667
|
# Reset browser session context
|
|
668
|
-
from code_puppy.tools.browser.
|
|
668
|
+
from code_puppy.tools.browser.browser_manager import (
|
|
669
669
|
_browser_session_var,
|
|
670
670
|
)
|
|
671
671
|
|
|
@@ -7,7 +7,7 @@ from pydantic_ai import RunContext
|
|
|
7
7
|
from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
|
|
8
8
|
from code_puppy.tools.common import generate_group_id
|
|
9
9
|
|
|
10
|
-
from .
|
|
10
|
+
from .browser_manager import get_session_browser_manager
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
async def initialize_browser(
|
|
@@ -7,7 +7,7 @@ from pydantic_ai import RunContext
|
|
|
7
7
|
from code_puppy.messaging import emit_error, emit_info, emit_success
|
|
8
8
|
from code_puppy.tools.common import generate_group_id
|
|
9
9
|
|
|
10
|
-
from .
|
|
10
|
+
from .browser_manager import get_session_browser_manager
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
async def click_element(
|
|
@@ -7,7 +7,7 @@ from pydantic_ai import RunContext
|
|
|
7
7
|
from code_puppy.messaging import emit_info, emit_success
|
|
8
8
|
from code_puppy.tools.common import generate_group_id
|
|
9
9
|
|
|
10
|
-
from .
|
|
10
|
+
from .browser_manager import get_session_browser_manager
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
async def find_by_role(
|