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.
Files changed (41) hide show
  1. code_puppy/agents/__init__.py +6 -0
  2. code_puppy/agents/agent_manager.py +223 -1
  3. code_puppy/agents/base_agent.py +2 -12
  4. code_puppy/chatgpt_codex_client.py +53 -0
  5. code_puppy/claude_cache_client.py +45 -7
  6. code_puppy/command_line/add_model_menu.py +13 -4
  7. code_puppy/command_line/agent_menu.py +662 -0
  8. code_puppy/command_line/core_commands.py +4 -112
  9. code_puppy/command_line/model_picker_completion.py +3 -20
  10. code_puppy/command_line/model_settings_menu.py +21 -3
  11. code_puppy/config.py +79 -8
  12. code_puppy/gemini_model.py +706 -0
  13. code_puppy/http_utils.py +6 -3
  14. code_puppy/model_factory.py +50 -16
  15. code_puppy/model_switching.py +63 -0
  16. code_puppy/model_utils.py +1 -52
  17. code_puppy/models.json +12 -12
  18. code_puppy/plugins/antigravity_oauth/antigravity_model.py +128 -165
  19. code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
  20. code_puppy/plugins/antigravity_oauth/transport.py +235 -45
  21. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
  22. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
  23. code_puppy/plugins/claude_code_oauth/utils.py +4 -1
  24. code_puppy/pydantic_patches.py +52 -0
  25. code_puppy/tools/agent_tools.py +3 -3
  26. code_puppy/tools/browser/__init__.py +1 -1
  27. code_puppy/tools/browser/browser_control.py +1 -1
  28. code_puppy/tools/browser/browser_interactions.py +1 -1
  29. code_puppy/tools/browser/browser_locators.py +1 -1
  30. code_puppy/tools/browser/{camoufox_manager.py → browser_manager.py} +29 -110
  31. code_puppy/tools/browser/browser_navigation.py +1 -1
  32. code_puppy/tools/browser/browser_screenshot.py +1 -1
  33. code_puppy/tools/browser/browser_scripts.py +1 -1
  34. {code_puppy-0.0.361.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
  35. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/METADATA +5 -6
  36. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/RECORD +40 -38
  37. code_puppy/prompts/codex_system_prompt.md +0 -310
  38. {code_puppy-0.0.361.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
  39. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
  40. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
  41. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
@@ -5,9 +5,12 @@ configurations, each with their own system prompts and tool sets.
5
5
  """
6
6
 
7
7
  from .agent_manager import (
8
+ clone_agent,
9
+ delete_clone_agent,
8
10
  get_agent_descriptions,
9
11
  get_available_agents,
10
12
  get_current_agent,
13
+ is_clone_agent_name,
11
14
  load_agent,
12
15
  refresh_agents,
13
16
  set_current_agent,
@@ -15,8 +18,11 @@ from .agent_manager import (
15
18
  from .subagent_stream_handler import subagent_stream_handler
16
19
 
17
20
  __all__ = [
21
+ "clone_agent",
22
+ "delete_clone_agent",
18
23
  "get_available_agents",
19
24
  "get_current_agent",
25
+ "is_clone_agent_name",
20
26
  "set_current_agent",
21
27
  "load_agent",
22
28
  "get_agent_descriptions",
@@ -4,6 +4,7 @@ import importlib
4
4
  import json
5
5
  import os
6
6
  import pkgutil
7
+ import re
7
8
  import uuid
8
9
  from pathlib import Path
9
10
  from typing import Dict, List, Optional, Type, Union
@@ -13,7 +14,7 @@ from pydantic_ai.messages import ModelMessage
13
14
  from code_puppy.agents.base_agent import BaseAgent
14
15
  from code_puppy.agents.json_agent import JSONAgent, discover_json_agents
15
16
  from code_puppy.callbacks import on_agent_reload
16
- from code_puppy.messaging import emit_warning
17
+ from code_puppy.messaging import emit_success, emit_warning
17
18
 
18
19
  # Registry of available agents (Python classes and JSON file paths)
19
20
  _AGENT_REGISTRY: Dict[str, Union[Type[BaseAgent], str]] = {}
@@ -295,12 +296,21 @@ def get_available_agents() -> Dict[str, str]:
295
296
  Returns:
296
297
  Dict mapping agent names to display names.
297
298
  """
299
+ from ..config import PACK_AGENT_NAMES, get_pack_agents_enabled
300
+
298
301
  # Generate a message group ID for this operation
299
302
  message_group_id = str(uuid.uuid4())
300
303
  _discover_agents(message_group_id=message_group_id)
301
304
 
305
+ # Check if pack agents are enabled
306
+ pack_agents_enabled = get_pack_agents_enabled()
307
+
302
308
  agents = {}
303
309
  for name, agent_ref in _AGENT_REGISTRY.items():
310
+ # Filter out pack agents if disabled
311
+ if not pack_agents_enabled and name in PACK_AGENT_NAMES:
312
+ continue
313
+
304
314
  try:
305
315
  if isinstance(agent_ref, str): # JSON agent (file path)
306
316
  agent_instance = JSONAgent(agent_ref)
@@ -423,12 +433,21 @@ def get_agent_descriptions() -> Dict[str, str]:
423
433
  Returns:
424
434
  Dict mapping agent names to their descriptions.
425
435
  """
436
+ from ..config import PACK_AGENT_NAMES, get_pack_agents_enabled
437
+
426
438
  # Generate a message group ID for this operation
427
439
  message_group_id = str(uuid.uuid4())
428
440
  _discover_agents(message_group_id=message_group_id)
429
441
 
442
+ # Check if pack agents are enabled
443
+ pack_agents_enabled = get_pack_agents_enabled()
444
+
430
445
  descriptions = {}
431
446
  for name, agent_ref in _AGENT_REGISTRY.items():
447
+ # Filter out pack agents if disabled
448
+ if not pack_agents_enabled and name in PACK_AGENT_NAMES:
449
+ continue
450
+
432
451
  try:
433
452
  if isinstance(agent_ref, str): # JSON agent (file path)
434
453
  agent_instance = JSONAgent(agent_ref)
@@ -449,3 +468,206 @@ def refresh_agents():
449
468
  # Generate a message group ID for agent refreshing
450
469
  message_group_id = str(uuid.uuid4())
451
470
  _discover_agents(message_group_id=message_group_id)
471
+
472
+
473
+ _CLONE_NAME_PATTERN = re.compile(r"^(?P<base>.+)-clone-(?P<index>\d+)$")
474
+ _CLONE_DISPLAY_PATTERN = re.compile(r"\s*\(Clone\s+\d+\)$", re.IGNORECASE)
475
+
476
+
477
+ def _strip_clone_suffix(agent_name: str) -> str:
478
+ """Strip a trailing -clone-N suffix from a name if present."""
479
+ match = _CLONE_NAME_PATTERN.match(agent_name)
480
+ return match.group("base") if match else agent_name
481
+
482
+
483
+ def _strip_clone_display_suffix(display_name: str) -> str:
484
+ """Remove a trailing "(Clone N)" suffix from display names."""
485
+ cleaned = _CLONE_DISPLAY_PATTERN.sub("", display_name).strip()
486
+ return cleaned or display_name
487
+
488
+
489
+ def is_clone_agent_name(agent_name: str) -> bool:
490
+ """Return True if the agent name looks like a clone."""
491
+ return bool(_CLONE_NAME_PATTERN.match(agent_name))
492
+
493
+
494
+ def _default_display_name(agent_name: str) -> str:
495
+ """Build a default display name from an agent name."""
496
+ title = agent_name.title()
497
+ return f"{title} 🤖"
498
+
499
+
500
+ def _build_clone_display_name(display_name: str, clone_index: int) -> str:
501
+ """Build a clone display name based on the source display name."""
502
+ base_name = _strip_clone_display_suffix(display_name)
503
+ return f"{base_name} (Clone {clone_index})"
504
+
505
+
506
+ def _filter_available_tools(tool_names: List[str]) -> List[str]:
507
+ """Filter a tool list to only available tool names."""
508
+ from code_puppy.tools import get_available_tool_names
509
+
510
+ available_tools = set(get_available_tool_names())
511
+ return [tool for tool in tool_names if tool in available_tools]
512
+
513
+
514
+ def _next_clone_index(
515
+ base_name: str, existing_names: set[str], agents_dir: Path
516
+ ) -> int:
517
+ """Compute the next clone index for a base name."""
518
+ clone_pattern = re.compile(rf"^{re.escape(base_name)}-clone-(\\d+)$")
519
+ indices = []
520
+ for name in existing_names:
521
+ match = clone_pattern.match(name)
522
+ if match:
523
+ indices.append(int(match.group(1)))
524
+
525
+ next_index = max(indices, default=0) + 1
526
+ while True:
527
+ clone_name = f"{base_name}-clone-{next_index}"
528
+ clone_path = agents_dir / f"{clone_name}.json"
529
+ if clone_name not in existing_names and not clone_path.exists():
530
+ return next_index
531
+ next_index += 1
532
+
533
+
534
+ def clone_agent(agent_name: str) -> Optional[str]:
535
+ """Clone an agent definition into the user agents directory.
536
+
537
+ Args:
538
+ agent_name: Source agent name to clone.
539
+
540
+ Returns:
541
+ The cloned agent name, or None if cloning failed.
542
+ """
543
+ # Generate a message group ID for agent cloning
544
+ message_group_id = str(uuid.uuid4())
545
+ _discover_agents(message_group_id=message_group_id)
546
+
547
+ agent_ref = _AGENT_REGISTRY.get(agent_name)
548
+ if agent_ref is None:
549
+ emit_warning(f"Agent '{agent_name}' not found for cloning.")
550
+ return None
551
+
552
+ from ..config import get_agent_pinned_model, get_user_agents_directory
553
+
554
+ agents_dir = Path(get_user_agents_directory())
555
+ base_name = _strip_clone_suffix(agent_name)
556
+ existing_names = set(_AGENT_REGISTRY.keys())
557
+ clone_index = _next_clone_index(base_name, existing_names, agents_dir)
558
+ clone_name = f"{base_name}-clone-{clone_index}"
559
+ clone_path = agents_dir / f"{clone_name}.json"
560
+
561
+ try:
562
+ if isinstance(agent_ref, str):
563
+ with open(agent_ref, "r", encoding="utf-8") as f:
564
+ source_config = json.load(f)
565
+
566
+ source_display_name = source_config.get("display_name")
567
+ if not source_display_name:
568
+ source_display_name = _default_display_name(base_name)
569
+
570
+ clone_config = dict(source_config)
571
+ clone_config["name"] = clone_name
572
+ clone_config["display_name"] = _build_clone_display_name(
573
+ source_display_name, clone_index
574
+ )
575
+
576
+ tools = source_config.get("tools", [])
577
+ clone_config["tools"] = (
578
+ _filter_available_tools(tools) if isinstance(tools, list) else []
579
+ )
580
+
581
+ if not clone_config.get("model"):
582
+ clone_config.pop("model", None)
583
+ else:
584
+ agent_instance = agent_ref()
585
+ clone_config = {
586
+ "name": clone_name,
587
+ "display_name": _build_clone_display_name(
588
+ agent_instance.display_name, clone_index
589
+ ),
590
+ "description": agent_instance.description,
591
+ "system_prompt": agent_instance.get_system_prompt(),
592
+ "tools": _filter_available_tools(agent_instance.get_available_tools()),
593
+ }
594
+
595
+ user_prompt = agent_instance.get_user_prompt()
596
+ if user_prompt is not None:
597
+ clone_config["user_prompt"] = user_prompt
598
+
599
+ tools_config = agent_instance.get_tools_config()
600
+ if tools_config is not None:
601
+ clone_config["tools_config"] = tools_config
602
+
603
+ pinned_model = get_agent_pinned_model(agent_instance.name)
604
+ if pinned_model:
605
+ clone_config["model"] = pinned_model
606
+ except Exception as exc:
607
+ emit_warning(f"Failed to build clone for '{agent_name}': {exc}")
608
+ return None
609
+
610
+ if clone_path.exists():
611
+ emit_warning(f"Clone target '{clone_name}' already exists.")
612
+ return None
613
+
614
+ try:
615
+ with open(clone_path, "w", encoding="utf-8") as f:
616
+ json.dump(clone_config, f, indent=2, ensure_ascii=False)
617
+ emit_success(f"Cloned '{agent_name}' to '{clone_name}'.")
618
+ return clone_name
619
+ except Exception as exc:
620
+ emit_warning(f"Failed to write clone file '{clone_path}': {exc}")
621
+ return None
622
+
623
+
624
+ def delete_clone_agent(agent_name: str) -> bool:
625
+ """Delete a cloned JSON agent definition.
626
+
627
+ Args:
628
+ agent_name: Clone agent name to delete.
629
+
630
+ Returns:
631
+ True if the clone was deleted, False otherwise.
632
+ """
633
+ message_group_id = str(uuid.uuid4())
634
+ _discover_agents(message_group_id=message_group_id)
635
+
636
+ if not is_clone_agent_name(agent_name):
637
+ emit_warning(f"Agent '{agent_name}' is not a clone.")
638
+ return False
639
+
640
+ if get_current_agent_name() == agent_name:
641
+ emit_warning("Cannot delete the active agent. Switch agents first.")
642
+ return False
643
+
644
+ agent_ref = _AGENT_REGISTRY.get(agent_name)
645
+ if agent_ref is None:
646
+ emit_warning(f"Clone '{agent_name}' not found.")
647
+ return False
648
+
649
+ if not isinstance(agent_ref, str):
650
+ emit_warning(f"Clone '{agent_name}' is not a JSON agent.")
651
+ return False
652
+
653
+ clone_path = Path(agent_ref)
654
+ if not clone_path.exists():
655
+ emit_warning(f"Clone file for '{agent_name}' does not exist.")
656
+ return False
657
+
658
+ from ..config import get_user_agents_directory
659
+
660
+ agents_dir = Path(get_user_agents_directory()).resolve()
661
+ if clone_path.resolve().parent != agents_dir:
662
+ emit_warning(f"Refusing to delete non-user clone '{agent_name}'.")
663
+ return False
664
+
665
+ try:
666
+ clone_path.unlink()
667
+ emit_success(f"Deleted clone '{agent_name}'.")
668
+ _AGENT_REGISTRY.pop(agent_name, None)
669
+ _AGENT_HISTORIES.pop(agent_name, None)
670
+ return True
671
+ except Exception as exc:
672
+ emit_warning(f"Failed to delete clone '{agent_name}': {exc}")
673
+ return False
@@ -378,10 +378,8 @@ class BaseAgent(ABC):
378
378
  try:
379
379
  from code_puppy.model_utils import (
380
380
  get_antigravity_instructions,
381
- get_chatgpt_codex_instructions,
382
381
  get_claude_code_instructions,
383
382
  is_antigravity_model,
384
- is_chatgpt_codex_model,
385
383
  is_claude_code_model,
386
384
  )
387
385
 
@@ -393,11 +391,6 @@ class BaseAgent(ABC):
393
391
  # The full system prompt is already in the message history
394
392
  instructions = get_claude_code_instructions()
395
393
  total_tokens += self.estimate_token_count(instructions)
396
- elif is_chatgpt_codex_model(model_name):
397
- # For ChatGPT Codex models, only count the short fixed instructions
398
- # The full system prompt is already in the message history
399
- instructions = get_chatgpt_codex_instructions()
400
- total_tokens += self.estimate_token_count(instructions)
401
394
  elif is_antigravity_model(model_name):
402
395
  # For Antigravity models, only count the short fixed instructions
403
396
  # The full system prompt is already in the message history
@@ -1568,14 +1561,11 @@ class BaseAgent(ABC):
1568
1561
  # Handle claude-code, chatgpt-codex, and antigravity models: prepend system prompt to first user message
1569
1562
  from code_puppy.model_utils import (
1570
1563
  is_antigravity_model,
1571
- is_chatgpt_codex_model,
1572
1564
  is_claude_code_model,
1573
1565
  )
1574
1566
 
1575
- if (
1576
- is_claude_code_model(self.get_model_name())
1577
- or is_chatgpt_codex_model(self.get_model_name())
1578
- or is_antigravity_model(self.get_model_name())
1567
+ if is_claude_code_model(self.get_model_name()) or is_antigravity_model(
1568
+ self.get_model_name()
1579
1569
  ):
1580
1570
  if len(self.get_message_history()) == 0:
1581
1571
  system_prompt = self.get_system_prompt()
@@ -156,6 +156,59 @@ class ChatGPTCodexAsyncClient(httpx.AsyncClient):
156
156
  }
157
157
  modified = True
158
158
 
159
+ # When `store=false` (Codex requirement), the backend does NOT persist input items.
160
+ # That means any later request that tries to reference a previous item by id will 404.
161
+ # We defensively strip reference-style items (especially reasoning_content) to avoid:
162
+ # "Item with id 'rs_...' not found. Items are not persisted when store is false."
163
+ input_items = data.get("input")
164
+ if data.get("store") is False and isinstance(input_items, list):
165
+ original_len = len(input_items)
166
+
167
+ def _looks_like_unpersisted_reference(it: dict) -> bool:
168
+ it_id = it.get("id")
169
+ if it_id in {"reasoning_content", "rs_reasoning_content"}:
170
+ return True
171
+
172
+ # Common reference-ish shapes: {"type": "input_item_reference", "id": "..."}
173
+ it_type = it.get("type")
174
+ if it_type in {"input_item_reference", "item_reference", "reference"}:
175
+ return True
176
+
177
+ # Ultra-conservative: if it's basically just an id (no actual content), drop it.
178
+ # A legit content item will typically have fields like `content`, `text`, `role`, etc.
179
+ non_id_keys = {k for k in it.keys() if k not in {"id", "type"}}
180
+ if not non_id_keys and isinstance(it_id, str) and it_id:
181
+ return True
182
+
183
+ return False
184
+
185
+ filtered: list[object] = []
186
+ for item in input_items:
187
+ if isinstance(item, dict) and _looks_like_unpersisted_reference(item):
188
+ modified = True
189
+ continue
190
+ filtered.append(item)
191
+
192
+ if len(filtered) != original_len:
193
+ data["input"] = filtered
194
+
195
+ # Normalize invalid input IDs (Codex expects reasoning ids to start with "rs_")
196
+ # Note: this is only safe for actual content items, NOT references.
197
+ input_items = data.get("input")
198
+ if isinstance(input_items, list):
199
+ for item in input_items:
200
+ if not isinstance(item, dict):
201
+ continue
202
+ item_id = item.get("id")
203
+ if (
204
+ isinstance(item_id, str)
205
+ and item_id
206
+ and "reasoning" in item_id
207
+ and not item_id.startswith("rs_")
208
+ ):
209
+ item["id"] = f"rs_{item_id}"
210
+ modified = True
211
+
159
212
  # Remove unsupported parameters
160
213
  # Note: verbosity should be under "text" object, not top-level
161
214
  unsupported_params = ["max_output_tokens", "max_tokens", "verbosity"]
@@ -119,24 +119,62 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
119
119
  return None
120
120
 
121
121
  def _should_refresh_token(self, request: httpx.Request) -> bool:
122
- """Check if the token in the request is older than 1 hour."""
122
+ """Check if the token should be refreshed (within 1 hour of expiry).
123
+
124
+ Uses two strategies:
125
+ 1. Decode JWT to check token age (if possible)
126
+ 2. Fall back to stored expires_at from token file
127
+
128
+ Returns True if token expires within TOKEN_MAX_AGE_SECONDS (1 hour).
129
+ """
123
130
  token = self._extract_bearer_token(request)
124
131
  if not token:
125
132
  return False
126
133
 
134
+ # Strategy 1: Try to decode JWT age
127
135
  age = self._get_jwt_age_seconds(token)
128
- if age is None:
129
- return False
130
-
131
- should_refresh = age >= TOKEN_MAX_AGE_SECONDS
136
+ if age is not None:
137
+ should_refresh = age >= TOKEN_MAX_AGE_SECONDS
138
+ if should_refresh:
139
+ logger.info(
140
+ "JWT token is %.1f seconds old (>= %d), will refresh proactively",
141
+ age,
142
+ TOKEN_MAX_AGE_SECONDS,
143
+ )
144
+ return should_refresh
145
+
146
+ # Strategy 2: Fall back to stored expires_at from token file
147
+ should_refresh = self._check_stored_token_expiry()
132
148
  if should_refresh:
133
149
  logger.info(
134
- "JWT token is %.1f seconds old (>= %d), will refresh proactively",
135
- age,
150
+ "Stored token expires within %d seconds, will refresh proactively",
136
151
  TOKEN_MAX_AGE_SECONDS,
137
152
  )
138
153
  return should_refresh
139
154
 
155
+ @staticmethod
156
+ def _check_stored_token_expiry() -> bool:
157
+ """Check if the stored token expires within TOKEN_MAX_AGE_SECONDS.
158
+
159
+ This is a fallback for when JWT decoding fails or isn't available.
160
+ Uses the expires_at timestamp from the stored token file.
161
+ """
162
+ try:
163
+ from code_puppy.plugins.claude_code_oauth.utils import (
164
+ is_token_expired,
165
+ load_stored_tokens,
166
+ )
167
+
168
+ tokens = load_stored_tokens()
169
+ if not tokens:
170
+ return False
171
+
172
+ # is_token_expired already uses TOKEN_REFRESH_BUFFER_SECONDS (1 hour)
173
+ return is_token_expired(tokens)
174
+ except Exception as exc:
175
+ logger.debug("Error checking stored token expiry: %s", exc)
176
+ return False
177
+
140
178
  @staticmethod
141
179
  def _prefix_tool_names(body: bytes) -> bytes | None:
142
180
  """Prefix all tool names in the request body with TOOL_PREFIX.
@@ -626,12 +626,21 @@ class AddModelMenu:
626
626
  elif model_type == "openai" and "gpt-5" in model.model_id:
627
627
  # GPT-5 models have special settings
628
628
  if "codex" in model.model_id:
629
- config["supported_settings"] = ["reasoning_effort"]
629
+ config["supported_settings"] = [
630
+ "temperature",
631
+ "top_p",
632
+ "reasoning_effort",
633
+ ]
630
634
  else:
631
- config["supported_settings"] = ["reasoning_effort", "verbosity"]
635
+ config["supported_settings"] = [
636
+ "temperature",
637
+ "top_p",
638
+ "reasoning_effort",
639
+ "verbosity",
640
+ ]
632
641
  else:
633
- # Default settings for most models (no top_p)
634
- config["supported_settings"] = ["temperature", "seed"]
642
+ # Default settings for most models
643
+ config["supported_settings"] = ["temperature", "seed", "top_p"]
635
644
 
636
645
  return config
637
646