code-puppy 0.0.373__py3-none-any.whl → 0.0.375__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 (44) hide show
  1. code_puppy/agents/agent_creator_agent.py +49 -1
  2. code_puppy/agents/agent_helios.py +122 -0
  3. code_puppy/agents/agent_manager.py +60 -4
  4. code_puppy/agents/base_agent.py +61 -4
  5. code_puppy/agents/json_agent.py +30 -7
  6. code_puppy/callbacks.py +125 -0
  7. code_puppy/command_line/colors_menu.py +2 -0
  8. code_puppy/command_line/command_handler.py +1 -0
  9. code_puppy/command_line/config_commands.py +3 -1
  10. code_puppy/command_line/uc_menu.py +890 -0
  11. code_puppy/config.py +29 -0
  12. code_puppy/messaging/messages.py +18 -0
  13. code_puppy/messaging/rich_renderer.py +48 -7
  14. code_puppy/messaging/subagent_console.py +0 -1
  15. code_puppy/model_factory.py +63 -258
  16. code_puppy/model_utils.py +33 -1
  17. code_puppy/plugins/antigravity_oauth/register_callbacks.py +106 -1
  18. code_puppy/plugins/antigravity_oauth/utils.py +2 -3
  19. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +85 -3
  20. code_puppy/plugins/claude_code_oauth/register_callbacks.py +88 -0
  21. code_puppy/plugins/ralph/__init__.py +13 -0
  22. code_puppy/plugins/ralph/agents.py +433 -0
  23. code_puppy/plugins/ralph/commands.py +208 -0
  24. code_puppy/plugins/ralph/loop_controller.py +285 -0
  25. code_puppy/plugins/ralph/models.py +125 -0
  26. code_puppy/plugins/ralph/register_callbacks.py +133 -0
  27. code_puppy/plugins/ralph/state_manager.py +322 -0
  28. code_puppy/plugins/ralph/tools.py +451 -0
  29. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  30. code_puppy/plugins/universal_constructor/models.py +138 -0
  31. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  32. code_puppy/plugins/universal_constructor/registry.py +304 -0
  33. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  34. code_puppy/tools/__init__.py +169 -1
  35. code_puppy/tools/agent_tools.py +1 -1
  36. code_puppy/tools/command_runner.py +23 -9
  37. code_puppy/tools/universal_constructor.py +889 -0
  38. {code_puppy-0.0.373.dist-info → code_puppy-0.0.375.dist-info}/METADATA +1 -1
  39. {code_puppy-0.0.373.dist-info → code_puppy-0.0.375.dist-info}/RECORD +44 -28
  40. {code_puppy-0.0.373.data → code_puppy-0.0.375.data}/data/code_puppy/models.json +0 -0
  41. {code_puppy-0.0.373.data → code_puppy-0.0.375.data}/data/code_puppy/models_dev_api.json +0 -0
  42. {code_puppy-0.0.373.dist-info → code_puppy-0.0.375.dist-info}/WHEEL +0 -0
  43. {code_puppy-0.0.373.dist-info → code_puppy-0.0.375.dist-info}/entry_points.txt +0 -0
  44. {code_puppy-0.0.373.dist-info → code_puppy-0.0.375.dist-info}/licenses/LICENSE +0 -0
code_puppy/config.py CHANGED
@@ -101,6 +101,9 @@ PACK_AGENT_NAMES = frozenset(
101
101
  ]
102
102
  )
103
103
 
104
+ # Agents that require Universal Constructor to be enabled
105
+ UC_AGENT_NAMES = frozenset(["helios"])
106
+
104
107
 
105
108
  def get_pack_agents_enabled() -> bool:
106
109
  """Return True if pack agents are enabled (default False).
@@ -117,6 +120,30 @@ def get_pack_agents_enabled() -> bool:
117
120
  return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
118
121
 
119
122
 
123
+ def get_universal_constructor_enabled() -> bool:
124
+ """Return True if the Universal Constructor is enabled (default True).
125
+
126
+ The Universal Constructor allows agents to dynamically create, manage,
127
+ and execute custom tools at runtime. When enabled, agents can extend
128
+ their capabilities by writing Python code that becomes callable tools.
129
+
130
+ When False, the universal_constructor tool is not registered with agents.
131
+ """
132
+ cfg_val = get_value("enable_universal_constructor")
133
+ if cfg_val is None:
134
+ return True # Enabled by default
135
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
136
+
137
+
138
+ def set_universal_constructor_enabled(enabled: bool) -> None:
139
+ """Enable or disable the Universal Constructor.
140
+
141
+ Args:
142
+ enabled: True to enable, False to disable
143
+ """
144
+ set_value("enable_universal_constructor", "true" if enabled else "false")
145
+
146
+
120
147
  DEFAULT_SECTION = "puppy"
121
148
  REQUIRED_KEYS = ["puppy_name", "owner_name"]
122
149
 
@@ -260,6 +287,8 @@ def get_config_keys():
260
287
  default_keys.append("enable_dbos")
261
288
  # Add pack agents control key
262
289
  default_keys.append("enable_pack_agents")
290
+ # Add universal constructor control key
291
+ default_keys.append("enable_universal_constructor")
263
292
  # Add cancel agent key configuration
264
293
  default_keys.append("cancel_agent_key")
265
294
  # Add banner color keys
@@ -317,6 +317,21 @@ class SubAgentStatusMessage(BaseMessage):
317
317
  )
318
318
 
319
319
 
320
+ class UniversalConstructorMessage(BaseMessage):
321
+ """Result of a universal_constructor operation."""
322
+
323
+ category: MessageCategory = MessageCategory.TOOL_OUTPUT
324
+ action: str = Field(
325
+ description="The UC action performed (list/call/create/update/info)"
326
+ )
327
+ tool_name: Optional[str] = Field(
328
+ default=None, description="Tool name if applicable"
329
+ )
330
+ success: bool = Field(description="Whether the operation succeeded")
331
+ summary: str = Field(description="Brief summary of the result")
332
+ details: Optional[str] = Field(default=None, description="Additional details")
333
+
334
+
320
335
  # =============================================================================
321
336
  # User Interaction Messages (Agent → User)
322
337
  # =============================================================================
@@ -443,6 +458,7 @@ AnyMessage = Union[
443
458
  SubAgentInvocationMessage,
444
459
  SubAgentResponseMessage,
445
460
  SubAgentStatusMessage,
461
+ UniversalConstructorMessage,
446
462
  UserInputRequest,
447
463
  ConfirmationRequest,
448
464
  SelectionRequest,
@@ -485,6 +501,8 @@ __all__ = [
485
501
  "SubAgentInvocationMessage",
486
502
  "SubAgentResponseMessage",
487
503
  "SubAgentStatusMessage",
504
+ # Universal Constructor
505
+ "UniversalConstructorMessage",
488
506
  # User interaction
489
507
  "UserInputRequest",
490
508
  "ConfirmationRequest",
@@ -48,6 +48,7 @@ from .messages import (
48
48
  SubAgentInvocationMessage,
49
49
  SubAgentResponseMessage,
50
50
  TextMessage,
51
+ UniversalConstructorMessage,
51
52
  UserInputRequest,
52
53
  VersionCheckMessage,
53
54
  )
@@ -287,6 +288,8 @@ class RichConsoleRenderer:
287
288
  elif isinstance(message, SubAgentResponseMessage):
288
289
  # Skip rendering - we now display sub-agent responses via display_non_streamed_result
289
290
  pass
291
+ elif isinstance(message, UniversalConstructorMessage):
292
+ self._render_universal_constructor(message)
290
293
  elif isinstance(message, UserInputRequest):
291
294
  # Can't handle async user input in sync context - skip
292
295
  self._console.print("[dim]User input requested (requires async)[/dim]")
@@ -672,15 +675,21 @@ class RichConsoleRenderer:
672
675
  self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
673
676
 
674
677
  def _render_shell_line(self, msg: ShellLineMessage) -> None:
675
- """Render shell output line preserving ANSI codes."""
676
- from rich.text import Text
678
+ """Render shell output line preserving ANSI codes and carriage returns."""
679
+ import sys
677
680
 
678
- # Use Text.from_ansi() to parse ANSI codes into Rich styling
679
- # This preserves colors while still being safe
680
- text = Text.from_ansi(msg.line)
681
+ from rich.text import Text
681
682
 
682
- # Make all shell output dim to reduce visual noise
683
- self._console.print(text, style="dim")
683
+ # Check if line contains carriage return (progress bar style output)
684
+ if "\r" in msg.line:
685
+ # Bypass Rich entirely - write directly to stdout so terminal interprets \r
686
+ # Apply dim styling manually via ANSI codes
687
+ sys.stdout.write(f"\033[2m{msg.line}\033[0m")
688
+ sys.stdout.flush()
689
+ else:
690
+ # Normal line: use Rich for nice formatting
691
+ text = Text.from_ansi(msg.line)
692
+ self._console.print(text, style="dim")
684
693
 
685
694
  def _render_shell_output(self, msg: ShellOutputMessage) -> None:
686
695
  """Render shell command output - just a trailing newline for spinner separation.
@@ -775,6 +784,38 @@ class RichConsoleRenderer:
775
784
  f"({msg.message_count} messages)[/dim]"
776
785
  )
777
786
 
787
+ def _render_universal_constructor(self, msg: UniversalConstructorMessage) -> None:
788
+ """Render universal_constructor tool output with banner."""
789
+ # Skip for sub-agents unless verbose mode
790
+ if self._should_suppress_subagent_output():
791
+ return
792
+
793
+ # Format banner
794
+ banner = self._format_banner("universal_constructor", "UNIVERSAL CONSTRUCTOR")
795
+
796
+ # Build the header line with action and optional tool name
797
+ # Escape user-controlled strings to prevent Rich markup injection
798
+ header_parts = [f"\n{banner} 🔧 [bold cyan]{msg.action.upper()}[/bold cyan]"]
799
+ if msg.tool_name:
800
+ safe_tool_name = escape_rich_markup(msg.tool_name)
801
+ header_parts.append(f" [dim]tool=[/dim][bold]{safe_tool_name}[/bold]")
802
+ self._console.print("".join(header_parts))
803
+
804
+ # Status indicator
805
+ safe_summary = escape_rich_markup(msg.summary) if msg.summary else ""
806
+ if msg.success:
807
+ self._console.print(f"[green]✓[/green] {safe_summary}")
808
+ else:
809
+ self._console.print(f"[red]✗[/red] {safe_summary}")
810
+
811
+ # Show details if present
812
+ if msg.details:
813
+ safe_details = escape_rich_markup(msg.details)
814
+ self._console.print(f"[dim]{safe_details}[/dim]")
815
+
816
+ # Trailing newline for spinner separation
817
+ self._console.print()
818
+
778
819
  # =========================================================================
779
820
  # User Interaction
780
821
  # =========================================================================
@@ -24,7 +24,6 @@ from rich.text import Text
24
24
 
25
25
  from code_puppy.messaging.messages import SubAgentStatusMessage
26
26
 
27
-
28
27
  # =============================================================================
29
28
  # Status Configuration
30
29
  # =============================================================================
@@ -393,78 +393,9 @@ class ModelFactory:
393
393
 
394
394
  provider = AnthropicProvider(anthropic_client=anthropic_client)
395
395
  return AnthropicModel(model_name=model_config["name"], provider=provider)
396
- elif model_type == "claude_code":
397
- url, headers, verify, api_key = get_custom_config(model_config)
398
- if model_config.get("oauth_source") == "claude-code-plugin":
399
- try:
400
- from code_puppy.plugins.claude_code_oauth.utils import (
401
- get_valid_access_token,
402
- )
403
-
404
- refreshed_token = get_valid_access_token()
405
- if refreshed_token:
406
- api_key = refreshed_token
407
- custom_endpoint = model_config.get("custom_endpoint")
408
- if isinstance(custom_endpoint, dict):
409
- custom_endpoint["api_key"] = refreshed_token
410
- except ImportError:
411
- pass
412
- if not api_key:
413
- emit_warning(
414
- f"API key is not set for Claude Code endpoint; skipping model '{model_config.get('name')}'."
415
- )
416
- return None
417
-
418
- # Check if interleaved thinking is enabled (defaults to True for OAuth models)
419
- from code_puppy.config import get_effective_model_settings
420
-
421
- effective_settings = get_effective_model_settings(model_name)
422
- interleaved_thinking = effective_settings.get("interleaved_thinking", True)
423
-
424
- # Handle anthropic-beta header based on interleaved_thinking setting
425
- if "anthropic-beta" in headers:
426
- beta_parts = [p.strip() for p in headers["anthropic-beta"].split(",")]
427
- if interleaved_thinking:
428
- # Ensure interleaved-thinking is in the header
429
- if "interleaved-thinking-2025-05-14" not in beta_parts:
430
- beta_parts.append("interleaved-thinking-2025-05-14")
431
- else:
432
- # Remove interleaved-thinking from the header
433
- beta_parts = [
434
- p for p in beta_parts if "interleaved-thinking" not in p
435
- ]
436
- headers["anthropic-beta"] = ",".join(beta_parts) if beta_parts else None
437
- if headers.get("anthropic-beta") is None:
438
- del headers["anthropic-beta"]
439
- elif interleaved_thinking:
440
- # No existing beta header, add one for interleaved thinking
441
- headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
442
-
443
- # Use a dedicated client wrapper that injects cache_control on /v1/messages
444
- if verify is None:
445
- verify = get_cert_bundle_path()
446
-
447
- http2_enabled = get_http2()
448
-
449
- client = ClaudeCacheAsyncClient(
450
- headers=headers,
451
- verify=verify,
452
- timeout=180,
453
- http2=http2_enabled,
454
- )
396
+ # NOTE: 'claude_code' model type is now handled by the claude_code_oauth plugin
397
+ # via the register_model_type callback. See plugins/claude_code_oauth/register_callbacks.py
455
398
 
456
- anthropic_client = AsyncAnthropic(
457
- base_url=url,
458
- http_client=client,
459
- auth_token=api_key,
460
- )
461
- # Ensure cache_control is injected at the Anthropic SDK layer too
462
- # so we don't depend solely on httpx internals.
463
- patch_anthropic_client_messages(anthropic_client)
464
- anthropic_client.api_key = None
465
- anthropic_client.auth_token = api_key
466
- provider = AnthropicProvider(anthropic_client=anthropic_client)
467
- return AnthropicModel(model_name=model_config["name"], provider=provider)
468
399
  elif model_type == "azure_openai":
469
400
  azure_endpoint_config = model_config.get("azure_endpoint")
470
401
  if not azure_endpoint_config:
@@ -571,7 +502,42 @@ class ModelFactory:
571
502
  )
572
503
  setattr(zai_model, "provider", provider)
573
504
  return zai_model
505
+ # NOTE: 'antigravity' model type is now handled by the antigravity_oauth plugin
506
+ # via the register_model_type callback. See plugins/antigravity_oauth/register_callbacks.py
507
+
574
508
  elif model_type == "custom_gemini":
509
+ # Backwards compatibility: delegate to antigravity plugin if antigravity flag is set
510
+ # New configs use type="antigravity" directly, but old configs may have
511
+ # type="custom_gemini" with antigravity=True
512
+ if model_config.get("antigravity"):
513
+ # Find and call the antigravity handler from the plugin
514
+ registered_handlers = callbacks.on_register_model_types()
515
+ for handler_info in registered_handlers:
516
+ handlers = (
517
+ handler_info
518
+ if isinstance(handler_info, list)
519
+ else [handler_info]
520
+ if handler_info
521
+ else []
522
+ )
523
+ for handler_entry in handlers:
524
+ if (
525
+ isinstance(handler_entry, dict)
526
+ and handler_entry.get("type") == "antigravity"
527
+ ):
528
+ handler = handler_entry.get("handler")
529
+ if callable(handler):
530
+ try:
531
+ return handler(model_name, model_config, config)
532
+ except Exception as e:
533
+ logger.error(f"Antigravity handler failed: {e}")
534
+ return None
535
+ # If no antigravity handler found, warn and fall through
536
+ emit_warning(
537
+ f"Model '{model_config.get('name')}' has antigravity=True but antigravity plugin not loaded."
538
+ )
539
+ return None
540
+
575
541
  url, headers, verify, api_key = get_custom_config(model_config)
576
542
  if not api_key:
577
543
  emit_warning(
@@ -579,114 +545,7 @@ class ModelFactory:
579
545
  )
580
546
  return None
581
547
 
582
- # Check if this is an Antigravity model
583
- if model_config.get("antigravity"):
584
- try:
585
- from code_puppy.plugins.antigravity_oauth.token import (
586
- is_token_expired,
587
- refresh_access_token,
588
- )
589
- from code_puppy.plugins.antigravity_oauth.transport import (
590
- create_antigravity_client,
591
- )
592
- from code_puppy.plugins.antigravity_oauth.utils import (
593
- load_stored_tokens,
594
- save_tokens,
595
- )
596
-
597
- # Try to import custom model for thinking signatures
598
- try:
599
- from code_puppy.plugins.antigravity_oauth.antigravity_model import (
600
- AntigravityModel,
601
- )
602
- except ImportError:
603
- AntigravityModel = None
604
-
605
- # Get fresh access token (refresh if needed)
606
- tokens = load_stored_tokens()
607
- if not tokens:
608
- emit_warning(
609
- "Antigravity tokens not found; run /antigravity-auth first."
610
- )
611
- return None
612
-
613
- access_token = tokens.get("access_token", "")
614
- refresh_token = tokens.get("refresh_token", "")
615
- expires_at = tokens.get("expires_at")
616
-
617
- # Refresh if expired or about to expire (initial check)
618
- if is_token_expired(expires_at):
619
- new_tokens = refresh_access_token(refresh_token)
620
- if new_tokens:
621
- access_token = new_tokens.access_token
622
- refresh_token = new_tokens.refresh_token
623
- expires_at = new_tokens.expires_at
624
- tokens["access_token"] = new_tokens.access_token
625
- tokens["refresh_token"] = new_tokens.refresh_token
626
- tokens["expires_at"] = new_tokens.expires_at
627
- save_tokens(tokens)
628
- else:
629
- emit_warning(
630
- "Failed to refresh Antigravity token; run /antigravity-auth again."
631
- )
632
- return None
633
-
634
- # Callback to persist tokens when proactively refreshed during session
635
- def on_token_refreshed(new_tokens):
636
- """Persist new tokens when proactively refreshed."""
637
- try:
638
- updated_tokens = load_stored_tokens() or {}
639
- updated_tokens["access_token"] = new_tokens.access_token
640
- updated_tokens["refresh_token"] = new_tokens.refresh_token
641
- updated_tokens["expires_at"] = new_tokens.expires_at
642
- save_tokens(updated_tokens)
643
- logger.debug(
644
- "Persisted proactively refreshed Antigravity tokens"
645
- )
646
- except Exception as e:
647
- logger.warning("Failed to persist refreshed tokens: %s", e)
648
-
649
- project_id = tokens.get(
650
- "project_id", model_config.get("project_id", "")
651
- )
652
- client = create_antigravity_client(
653
- access_token=access_token,
654
- project_id=project_id,
655
- model_name=model_config["name"],
656
- base_url=url,
657
- headers=headers,
658
- refresh_token=refresh_token,
659
- expires_at=expires_at,
660
- on_token_refreshed=on_token_refreshed,
661
- )
662
-
663
- # Use custom model with direct httpx client
664
- if AntigravityModel:
665
- model = AntigravityModel(
666
- model_name=model_config["name"],
667
- api_key=api_key
668
- or "", # Antigravity uses OAuth, key may be empty
669
- base_url=url,
670
- http_client=client,
671
- )
672
- else:
673
- model = GeminiModel(
674
- model_name=model_config["name"],
675
- api_key=api_key or "",
676
- base_url=url,
677
- http_client=client,
678
- )
679
-
680
- return model
681
-
682
- except ImportError:
683
- emit_warning(
684
- f"Antigravity transport not available; skipping model '{model_config.get('name')}'."
685
- )
686
- return None
687
- else:
688
- client = create_async_client(headers=headers, verify=verify)
689
-
548
+ client = create_async_client(headers=headers, verify=verify)
690
549
  model = GeminiModel(
691
550
  model_name=model_config["name"],
692
551
  api_key=api_key,
@@ -814,85 +673,8 @@ class ModelFactory:
814
673
  )
815
674
  return model
816
675
 
817
- elif model_type == "chatgpt_oauth":
818
- # ChatGPT OAuth models use the Codex API at chatgpt.com
819
- try:
820
- try:
821
- from chatgpt_oauth.config import CHATGPT_OAUTH_CONFIG
822
- from chatgpt_oauth.utils import (
823
- get_valid_access_token,
824
- load_stored_tokens,
825
- )
826
- except ImportError:
827
- from code_puppy.plugins.chatgpt_oauth.config import (
828
- CHATGPT_OAUTH_CONFIG,
829
- )
830
- from code_puppy.plugins.chatgpt_oauth.utils import (
831
- get_valid_access_token,
832
- load_stored_tokens,
833
- )
834
- except ImportError as exc:
835
- emit_warning(
836
- f"ChatGPT OAuth plugin not available; skipping model '{model_config.get('name')}'. "
837
- f"Error: {exc}"
838
- )
839
- return None
840
-
841
- # Get a valid access token (refreshing if needed)
842
- access_token = get_valid_access_token()
843
- if not access_token:
844
- emit_warning(
845
- f"Failed to get valid ChatGPT OAuth token; skipping model '{model_config.get('name')}'. "
846
- "Run /chatgpt-auth to authenticate."
847
- )
848
- return None
849
-
850
- # Get account_id from stored tokens (required for ChatGPT-Account-Id header)
851
- tokens = load_stored_tokens()
852
- account_id = tokens.get("account_id", "") if tokens else ""
853
- if not account_id:
854
- emit_warning(
855
- f"No account_id found in ChatGPT OAuth tokens; skipping model '{model_config.get('name')}'. "
856
- "Run /chatgpt-auth to re-authenticate."
857
- )
858
- return None
859
-
860
- # Build headers for ChatGPT Codex API
861
- originator = CHATGPT_OAUTH_CONFIG.get("originator", "codex_cli_rs")
862
- client_version = CHATGPT_OAUTH_CONFIG.get("client_version", "0.72.0")
863
-
864
- headers = {
865
- "ChatGPT-Account-Id": account_id,
866
- "originator": originator,
867
- "User-Agent": f"{originator}/{client_version}",
868
- }
869
- # Merge with any headers from model config
870
- config_headers = model_config.get("custom_endpoint", {}).get("headers", {})
871
- headers.update(config_headers)
872
-
873
- # Get base URL - Codex API uses chatgpt.com, not api.openai.com
874
- base_url = model_config.get("custom_endpoint", {}).get(
875
- "url", CHATGPT_OAUTH_CONFIG["api_base_url"]
876
- )
877
-
878
- # Create HTTP client with Codex interceptor for store=false injection
879
- from code_puppy.chatgpt_codex_client import create_codex_async_client
880
-
881
- verify = get_cert_bundle_path()
882
- client = create_codex_async_client(headers=headers, verify=verify)
883
-
884
- provider = OpenAIProvider(
885
- api_key=access_token,
886
- base_url=base_url,
887
- http_client=client,
888
- )
889
-
890
- # ChatGPT Codex API only supports Responses format
891
- model = OpenAIResponsesModel(
892
- model_name=model_config["name"], provider=provider
893
- )
894
- setattr(model, "provider", provider)
895
- return model
676
+ # NOTE: 'chatgpt_oauth' model type is now handled by the chatgpt_oauth plugin
677
+ # via the register_model_type callback. See plugins/chatgpt_oauth/register_callbacks.py
896
678
 
897
679
  elif model_type == "round_robin":
898
680
  # Get the list of model names to use in the round-robin
@@ -916,4 +698,27 @@ class ModelFactory:
916
698
  return RoundRobinModel(*models, rotate_every=rotate_every)
917
699
 
918
700
  else:
701
+ # Check for plugin-registered model type handlers
702
+ registered_handlers = callbacks.on_register_model_types()
703
+ for handler_info in registered_handlers:
704
+ # Handler info can be a list of dicts or a single dict
705
+ if isinstance(handler_info, list):
706
+ handlers = handler_info
707
+ else:
708
+ handlers = [handler_info] if handler_info else []
709
+
710
+ for handler_entry in handlers:
711
+ if not isinstance(handler_entry, dict):
712
+ continue
713
+ if handler_entry.get("type") == model_type:
714
+ handler = handler_entry.get("handler")
715
+ if callable(handler):
716
+ try:
717
+ return handler(model_name, model_config, config)
718
+ except Exception as e:
719
+ logger.error(
720
+ f"Plugin handler for model type '{model_type}' failed: {e}"
721
+ )
722
+ return None
723
+
919
724
  raise ValueError(f"Unsupported model type: {model_type}")
code_puppy/model_utils.py CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  This module centralizes logic for handling model-specific behaviors,
4
4
  particularly for claude-code and antigravity models which require special prompt handling.
5
+
6
+ Plugins can register custom system prompt handlers via the 'get_model_system_prompt'
7
+ callback to extend support for additional model types.
5
8
  """
6
9
 
7
10
  import pathlib
@@ -68,7 +71,36 @@ def prepare_prompt_for_model(
68
71
  user_prompt: str,
69
72
  prepend_system_to_user: bool = True,
70
73
  ) -> PreparedPrompt:
71
- """Prepare instructions and prompt for a specific model."""
74
+ """Prepare instructions and prompt for a specific model.
75
+
76
+ This function handles model-specific system prompt requirements. Plugins can
77
+ register custom handlers via the 'get_model_system_prompt' callback to extend
78
+ support for additional model types.
79
+
80
+ Args:
81
+ model_name: The name of the model being used
82
+ system_prompt: The default system prompt from the agent
83
+ user_prompt: The user's prompt/message
84
+ prepend_system_to_user: Whether to prepend system prompt to user prompt
85
+ for models that require it (default: True)
86
+
87
+ Returns:
88
+ PreparedPrompt with instructions and user_prompt ready for the model.
89
+ """
90
+ # Check for plugin-registered system prompt handlers first
91
+ from code_puppy import callbacks
92
+
93
+ results = callbacks.on_get_model_system_prompt(
94
+ model_name, system_prompt, user_prompt
95
+ )
96
+ for result in results:
97
+ if result and isinstance(result, dict) and result.get("handled"):
98
+ return PreparedPrompt(
99
+ instructions=result.get("instructions", system_prompt),
100
+ user_prompt=result.get("user_prompt", user_prompt),
101
+ is_claude_code=result.get("is_claude_code", False),
102
+ )
103
+
72
104
  # Handle Claude Code models
73
105
  if is_claude_code_model(model_name):
74
106
  modified_prompt = user_prompt