code-puppy 0.0.374__py3-none-any.whl → 0.0.376__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 (30) hide show
  1. code_puppy/agents/agent_manager.py +34 -2
  2. code_puppy/agents/base_agent.py +122 -41
  3. code_puppy/callbacks.py +173 -0
  4. code_puppy/messaging/rich_renderer.py +13 -7
  5. code_puppy/model_factory.py +63 -258
  6. code_puppy/model_utils.py +33 -1
  7. code_puppy/plugins/antigravity_oauth/register_callbacks.py +106 -1
  8. code_puppy/plugins/antigravity_oauth/utils.py +2 -3
  9. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +85 -3
  10. code_puppy/plugins/claude_code_oauth/__init__.py +19 -0
  11. code_puppy/plugins/claude_code_oauth/register_callbacks.py +160 -0
  12. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +242 -0
  13. code_puppy/plugins/ralph/__init__.py +13 -0
  14. code_puppy/plugins/ralph/agents.py +433 -0
  15. code_puppy/plugins/ralph/commands.py +208 -0
  16. code_puppy/plugins/ralph/loop_controller.py +289 -0
  17. code_puppy/plugins/ralph/models.py +125 -0
  18. code_puppy/plugins/ralph/register_callbacks.py +140 -0
  19. code_puppy/plugins/ralph/state_manager.py +322 -0
  20. code_puppy/plugins/ralph/tools.py +451 -0
  21. code_puppy/tools/__init__.py +31 -0
  22. code_puppy/tools/agent_tools.py +1 -1
  23. code_puppy/tools/command_runner.py +23 -9
  24. {code_puppy-0.0.374.dist-info → code_puppy-0.0.376.dist-info}/METADATA +1 -1
  25. {code_puppy-0.0.374.dist-info → code_puppy-0.0.376.dist-info}/RECORD +30 -21
  26. {code_puppy-0.0.374.data → code_puppy-0.0.376.data}/data/code_puppy/models.json +0 -0
  27. {code_puppy-0.0.374.data → code_puppy-0.0.376.data}/data/code_puppy/models_dev_api.json +0 -0
  28. {code_puppy-0.0.374.dist-info → code_puppy-0.0.376.dist-info}/WHEEL +0 -0
  29. {code_puppy-0.0.374.dist-info → code_puppy-0.0.376.dist-info}/entry_points.txt +0 -0
  30. {code_puppy-0.0.374.dist-info → code_puppy-0.0.376.dist-info}/licenses/LICENSE +0 -0
@@ -13,7 +13,7 @@ from pydantic_ai.messages import ModelMessage
13
13
 
14
14
  from code_puppy.agents.base_agent import BaseAgent
15
15
  from code_puppy.agents.json_agent import JSONAgent, discover_json_agents
16
- from code_puppy.callbacks import on_agent_reload
16
+ from code_puppy.callbacks import on_agent_reload, on_register_agents
17
17
  from code_puppy.messaging import emit_success, emit_warning
18
18
 
19
19
  # Registry of available agents (Python classes and JSON file paths)
@@ -289,6 +289,38 @@ def _discover_agents(message_group_id: Optional[str] = None):
289
289
  message_group=message_group_id,
290
290
  )
291
291
 
292
+ # 3. Discover agents registered by plugins
293
+ try:
294
+ results = on_register_agents()
295
+ for result in results:
296
+ if result is None:
297
+ continue
298
+ # Each result should be a list of agent definitions
299
+ agents_list = result if isinstance(result, list) else [result]
300
+ for agent_def in agents_list:
301
+ if not isinstance(agent_def, dict) or "name" not in agent_def:
302
+ continue
303
+
304
+ agent_name = agent_def["name"]
305
+
306
+ # Support both class-based and JSON path-based registration
307
+ if "class" in agent_def:
308
+ agent_class = agent_def["class"]
309
+ if isinstance(agent_class, type) and issubclass(
310
+ agent_class, BaseAgent
311
+ ):
312
+ _AGENT_REGISTRY[agent_name] = agent_class
313
+ elif "json_path" in agent_def:
314
+ json_path = agent_def["json_path"]
315
+ if isinstance(json_path, str):
316
+ _AGENT_REGISTRY[agent_name] = json_path
317
+
318
+ except Exception as e:
319
+ emit_warning(
320
+ f"Warning: Could not load plugin agents: {e}",
321
+ message_group=message_group_id,
322
+ )
323
+
292
324
 
293
325
  def get_available_agents() -> Dict[str, str]:
294
326
  """Get a dictionary of available agents with their display names.
@@ -612,7 +644,7 @@ def clone_agent(agent_name: str) -> Optional[str]:
612
644
  agent_instance.display_name, clone_index
613
645
  ),
614
646
  "description": agent_instance.description,
615
- "system_prompt": agent_instance.get_system_prompt(),
647
+ "system_prompt": agent_instance.get_full_system_prompt(),
616
648
  "tools": _filter_available_tools(agent_instance.get_available_tools()),
617
649
  }
618
650
 
@@ -47,6 +47,10 @@ from pydantic_ai.messages import (
47
47
  from rich.text import Text
48
48
 
49
49
  from code_puppy.agents.event_stream_handler import event_stream_handler
50
+ from code_puppy.callbacks import (
51
+ on_agent_run_end,
52
+ on_agent_run_start,
53
+ )
50
54
 
51
55
  # Consolidated relative imports
52
56
  from code_puppy.config import (
@@ -101,6 +105,37 @@ class BaseAgent(ABC):
101
105
  # This is populated after the first successful run when MCP tools are retrieved
102
106
  self._mcp_tool_definitions_cache: List[Dict[str, Any]] = []
103
107
 
108
+ def get_identity(self) -> str:
109
+ """Get a unique identity for this agent instance.
110
+
111
+ Returns:
112
+ A string like 'python-programmer-a3f2b1' combining name + short UUID.
113
+ """
114
+ return f"{self.name}-{self.id[:6]}"
115
+
116
+ def get_identity_prompt(self) -> str:
117
+ """Get the identity prompt suffix to embed in system prompts.
118
+
119
+ Returns:
120
+ A string instructing the agent about its identity for task ownership.
121
+ """
122
+ return (
123
+ f"\n\nYour ID is `{self.get_identity()}`. "
124
+ "Use this for any tasks which require identifying yourself "
125
+ "such as claiming task ownership or coordination with other agents."
126
+ )
127
+
128
+ def get_full_system_prompt(self) -> str:
129
+ """Get the complete system prompt with identity automatically appended.
130
+
131
+ This wraps get_system_prompt() and appends the agent's identity,
132
+ so subclasses don't need to worry about it.
133
+
134
+ Returns:
135
+ The full system prompt including identity information.
136
+ """
137
+ return self.get_system_prompt() + self.get_identity_prompt()
138
+
104
139
  @property
105
140
  @abstractmethod
106
141
  def name(self) -> str:
@@ -372,35 +407,27 @@ class BaseAgent(ABC):
372
407
  total_tokens = 0
373
408
 
374
409
  # 1. Estimate tokens for system prompt / instructions
375
- # For Claude Code models, the full system prompt is prepended to the first
376
- # user message (already in message history), so we only count the short
377
- # fixed instructions. For other models, count the full system prompt.
410
+ # Use prepare_prompt_for_model() to get the correct instructions for token counting.
411
+ # For models that prepend system prompt to user message (claude-code, antigravity),
412
+ # this returns the short fixed instructions. For other models, returns full prompt.
378
413
  try:
379
- from code_puppy.model_utils import (
380
- get_antigravity_instructions,
381
- get_claude_code_instructions,
382
- is_antigravity_model,
383
- is_claude_code_model,
384
- )
414
+ from code_puppy.model_utils import prepare_prompt_for_model
385
415
 
386
416
  model_name = (
387
417
  self.get_model_name() if hasattr(self, "get_model_name") else ""
388
418
  )
389
- if is_claude_code_model(model_name):
390
- # For Claude Code models, only count the short fixed instructions
391
- # The full system prompt is already in the message history
392
- instructions = get_claude_code_instructions()
393
- total_tokens += self.estimate_token_count(instructions)
394
- elif is_antigravity_model(model_name):
395
- # For Antigravity models, only count the short fixed instructions
396
- # The full system prompt is already in the message history
397
- instructions = get_antigravity_instructions()
398
- total_tokens += self.estimate_token_count(instructions)
399
- else:
400
- # For other models, count the full system prompt
401
- system_prompt = self.get_system_prompt()
402
- if system_prompt:
403
- total_tokens += self.estimate_token_count(system_prompt)
419
+ system_prompt = self.get_full_system_prompt()
420
+
421
+ # Get the instructions that will be used (handles model-specific logic via hooks)
422
+ prepared = prepare_prompt_for_model(
423
+ model_name=model_name,
424
+ system_prompt=system_prompt,
425
+ user_prompt="", # Empty - we just need the instructions
426
+ prepend_system_to_user=False, # Don't modify prompt, just get instructions
427
+ )
428
+
429
+ if prepared.instructions:
430
+ total_tokens += self.estimate_token_count(prepared.instructions)
404
431
  except Exception:
405
432
  pass # If we can't get system prompt, skip it
406
433
 
@@ -1122,7 +1149,7 @@ class BaseAgent(ABC):
1122
1149
  message_group,
1123
1150
  )
1124
1151
 
1125
- instructions = self.get_system_prompt()
1152
+ instructions = self.get_full_system_prompt()
1126
1153
  puppy_rules = self.load_puppy_rules()
1127
1154
  if puppy_rules:
1128
1155
  instructions += f"\n{puppy_rules}"
@@ -1286,7 +1313,7 @@ class BaseAgent(ABC):
1286
1313
  model_name, models_config, str(uuid.uuid4())
1287
1314
  )
1288
1315
 
1289
- instructions = self.get_system_prompt()
1316
+ instructions = self.get_full_system_prompt()
1290
1317
  puppy_rules = self.load_puppy_rules()
1291
1318
  if puppy_rules:
1292
1319
  instructions += f"\n{puppy_rules}"
@@ -1558,21 +1585,25 @@ class BaseAgent(ABC):
1558
1585
  if output_type is not None:
1559
1586
  pydantic_agent = self._create_agent_with_output_type(output_type)
1560
1587
 
1561
- # Handle claude-code, chatgpt-codex, and antigravity models: prepend system prompt to first user message
1562
- from code_puppy.model_utils import (
1563
- is_antigravity_model,
1564
- is_claude_code_model,
1565
- )
1588
+ # Handle model-specific prompt transformations via prepare_prompt_for_model()
1589
+ # This uses the get_model_system_prompt hook, so plugins can register their own handlers
1590
+ from code_puppy.model_utils import prepare_prompt_for_model
1566
1591
 
1567
- if is_claude_code_model(self.get_model_name()) or is_antigravity_model(
1568
- self.get_model_name()
1569
- ):
1570
- if len(self.get_message_history()) == 0:
1571
- system_prompt = self.get_system_prompt()
1572
- puppy_rules = self.load_puppy_rules()
1573
- if puppy_rules:
1574
- system_prompt += f"\n{puppy_rules}"
1575
- prompt = system_prompt + "\n\n" + prompt
1592
+ # Only prepend system prompt on first message (empty history)
1593
+ should_prepend = len(self.get_message_history()) == 0
1594
+ if should_prepend:
1595
+ system_prompt = self.get_full_system_prompt()
1596
+ puppy_rules = self.load_puppy_rules()
1597
+ if puppy_rules:
1598
+ system_prompt += f"\n{puppy_rules}"
1599
+
1600
+ prepared = prepare_prompt_for_model(
1601
+ model_name=self.get_model_name(),
1602
+ system_prompt=system_prompt,
1603
+ user_prompt=prompt,
1604
+ prepend_system_to_user=True,
1605
+ )
1606
+ prompt = prepared.user_prompt
1576
1607
 
1577
1608
  # Build combined prompt payload when attachments are provided.
1578
1609
  attachment_parts: List[Any] = []
@@ -1719,6 +1750,17 @@ class BaseAgent(ABC):
1719
1750
  # Create the task FIRST
1720
1751
  agent_task = asyncio.create_task(run_agent_task())
1721
1752
 
1753
+ # Fire agent_run_start hook - plugins can use this to start background tasks
1754
+ # (e.g., token refresh heartbeats for OAuth models)
1755
+ try:
1756
+ await on_agent_run_start(
1757
+ agent_name=self.name,
1758
+ model_name=self.get_model_name(),
1759
+ session_id=group_id,
1760
+ )
1761
+ except Exception:
1762
+ pass # Don't fail agent run if hook fails
1763
+
1722
1764
  # Import shell process status helper
1723
1765
 
1724
1766
  loop = asyncio.get_running_loop()
@@ -1800,14 +1842,53 @@ class BaseAgent(ABC):
1800
1842
  except Exception:
1801
1843
  pass # Don't fail the run if cache update fails
1802
1844
 
1845
+ # Extract response text for the callback
1846
+ _run_response_text = ""
1847
+ if result is not None:
1848
+ if hasattr(result, "data"):
1849
+ _run_response_text = str(result.data) if result.data else ""
1850
+ elif hasattr(result, "output"):
1851
+ _run_response_text = str(result.output) if result.output else ""
1852
+ else:
1853
+ _run_response_text = str(result)
1854
+
1855
+ _run_success = True
1856
+ _run_error = None
1803
1857
  return result
1804
1858
  except asyncio.CancelledError:
1859
+ _run_success = False
1860
+ _run_error = None # Cancellation is not an error
1861
+ _run_response_text = ""
1805
1862
  agent_task.cancel()
1806
1863
  except KeyboardInterrupt:
1807
- # Handle direct keyboard interrupt during await
1864
+ _run_success = False
1865
+ _run_error = None # User interrupt is not an error
1866
+ _run_response_text = ""
1808
1867
  if not agent_task.done():
1809
1868
  agent_task.cancel()
1869
+ except Exception as e:
1870
+ _run_success = False
1871
+ _run_error = e
1872
+ _run_response_text = ""
1873
+ raise
1810
1874
  finally:
1875
+ # Fire agent_run_end hook - plugins can use this for:
1876
+ # - Stopping background tasks (token refresh heartbeats)
1877
+ # - Workflow orchestration (Ralph's autonomous loop)
1878
+ # - Logging/analytics
1879
+ try:
1880
+ await on_agent_run_end(
1881
+ agent_name=self.name,
1882
+ model_name=self.get_model_name(),
1883
+ session_id=group_id,
1884
+ success=_run_success,
1885
+ error=_run_error,
1886
+ response_text=_run_response_text,
1887
+ metadata={"model": self.get_model_name()},
1888
+ )
1889
+ except Exception:
1890
+ pass # Don't fail cleanup if hook fails
1891
+
1811
1892
  # Stop keyboard listener if it was started
1812
1893
  if key_listener_stop_event is not None:
1813
1894
  key_listener_stop_event.set()
code_puppy/callbacks.py CHANGED
@@ -21,6 +21,12 @@ PhaseType = Literal[
21
21
  "pre_tool_call",
22
22
  "post_tool_call",
23
23
  "stream_event",
24
+ "register_tools",
25
+ "register_agents",
26
+ "register_model_type",
27
+ "get_model_system_prompt",
28
+ "agent_run_start",
29
+ "agent_run_end",
24
30
  ]
25
31
  CallbackFunc = Callable[..., Any]
26
32
 
@@ -42,6 +48,12 @@ _callbacks: Dict[PhaseType, List[CallbackFunc]] = {
42
48
  "pre_tool_call": [],
43
49
  "post_tool_call": [],
44
50
  "stream_event": [],
51
+ "register_tools": [],
52
+ "register_agents": [],
53
+ "register_model_type": [],
54
+ "get_model_system_prompt": [],
55
+ "agent_run_start": [],
56
+ "agent_run_end": [],
45
57
  }
46
58
 
47
59
  logger = logging.getLogger(__name__)
@@ -344,3 +356,164 @@ async def on_stream_event(
344
356
  return await _trigger_callbacks(
345
357
  "stream_event", event_type, event_data, agent_session_id
346
358
  )
359
+
360
+
361
+ def on_register_tools() -> List[Dict[str, Any]]:
362
+ """Collect custom tool registrations from plugins.
363
+
364
+ Each callback should return a list of dicts with:
365
+ - "name": str - the tool name
366
+ - "register_func": callable - function that takes an agent and registers the tool
367
+
368
+ Example return: [{"name": "my_tool", "register_func": register_my_tool}]
369
+ """
370
+ return _trigger_callbacks_sync("register_tools")
371
+
372
+
373
+ def on_register_agents() -> List[Dict[str, Any]]:
374
+ """Collect custom agent registrations from plugins.
375
+
376
+ Each callback should return a list of dicts with either:
377
+ - "name": str, "class": Type[BaseAgent] - for Python agent classes
378
+ - "name": str, "json_path": str - for JSON agent files
379
+
380
+ Example return: [{"name": "my-agent", "class": MyAgentClass}]
381
+ """
382
+ return _trigger_callbacks_sync("register_agents")
383
+
384
+
385
+ def on_register_model_types() -> List[Dict[str, Any]]:
386
+ """Collect custom model type registrations from plugins.
387
+
388
+ This hook allows plugins to register custom model types that can be used
389
+ in model configurations. Each callback should return a list of dicts with:
390
+ - "type": str - the model type name (e.g., "antigravity", "claude_code")
391
+ - "handler": callable - function(model_name, model_config, config) -> model instance
392
+
393
+ The handler function receives:
394
+ - model_name: str - the name of the model being created
395
+ - model_config: dict - the model's configuration from models.json
396
+ - config: dict - the full models configuration
397
+
398
+ The handler should return a model instance or None if creation fails.
399
+
400
+ Example callback:
401
+ def register_my_model_types():
402
+ return [{
403
+ "type": "my_custom_type",
404
+ "handler": create_my_custom_model,
405
+ }]
406
+
407
+ Example return: [{"type": "antigravity", "handler": create_antigravity_model}]
408
+ """
409
+ return _trigger_callbacks_sync("register_model_type")
410
+
411
+
412
+ def on_get_model_system_prompt(
413
+ model_name: str, default_system_prompt: str, user_prompt: str
414
+ ) -> List[Dict[str, Any]]:
415
+ """Allow plugins to provide custom system prompts for specific model types.
416
+
417
+ This hook allows plugins to override the system prompt handling for custom
418
+ model types (like claude_code or antigravity models). Each callback receives
419
+ the model name and should return a dict if it handles that model type, or None.
420
+
421
+ Args:
422
+ model_name: The name of the model being used (e.g., "claude-code-sonnet")
423
+ default_system_prompt: The default system prompt from the agent
424
+ user_prompt: The user's prompt/message
425
+
426
+ Each callback should return a dict with:
427
+ - "instructions": str - the system prompt/instructions to use
428
+ - "user_prompt": str - the (possibly modified) user prompt
429
+ - "handled": bool - True if this callback handled the model
430
+
431
+ Or return None if the callback doesn't handle this model type.
432
+
433
+ Example callback:
434
+ def get_my_model_system_prompt(model_name, default_system_prompt, user_prompt):
435
+ if model_name.startswith("my-custom-"):
436
+ return {
437
+ "instructions": "You are MyCustomBot.",
438
+ "user_prompt": f"{default_system_prompt}\n\n{user_prompt}",
439
+ "handled": True,
440
+ }
441
+ return None # Not handled by this callback
442
+
443
+ Returns:
444
+ List of results from registered callbacks (dicts or None values).
445
+ """
446
+ return _trigger_callbacks_sync(
447
+ "get_model_system_prompt", model_name, default_system_prompt, user_prompt
448
+ )
449
+
450
+
451
+ async def on_agent_run_start(
452
+ agent_name: str,
453
+ model_name: str,
454
+ session_id: str | None = None,
455
+ ) -> List[Any]:
456
+ """Trigger callbacks when an agent run starts.
457
+
458
+ This fires at the beginning of run_with_mcp, before the agent task is created.
459
+ Useful for:
460
+ - Starting background tasks (like token refresh heartbeats)
461
+ - Logging/analytics
462
+ - Resource allocation
463
+
464
+ Args:
465
+ agent_name: Name of the agent starting
466
+ model_name: Name of the model being used
467
+ session_id: Optional session identifier
468
+
469
+ Returns:
470
+ List of results from registered callbacks.
471
+ """
472
+ return await _trigger_callbacks(
473
+ "agent_run_start", agent_name, model_name, session_id
474
+ )
475
+
476
+
477
+ async def on_agent_run_end(
478
+ agent_name: str,
479
+ model_name: str,
480
+ session_id: str | None = None,
481
+ success: bool = True,
482
+ error: Exception | None = None,
483
+ response_text: str | None = None,
484
+ metadata: dict | None = None,
485
+ ) -> List[Any]:
486
+ """Trigger callbacks when an agent run ends.
487
+
488
+ This fires at the end of run_with_mcp, in the finally block.
489
+ Always fires regardless of success/failure/cancellation.
490
+
491
+ Useful for:
492
+ - Stopping background tasks (like token refresh heartbeats)
493
+ - Workflow orchestration (like Ralph's autonomous loop)
494
+ - Logging/analytics
495
+ - Resource cleanup
496
+ - Detecting completion signals in responses
497
+
498
+ Args:
499
+ agent_name: Name of the agent that finished
500
+ model_name: Name of the model that was used
501
+ session_id: Optional session identifier
502
+ success: Whether the run completed successfully
503
+ error: Exception if the run failed, None otherwise
504
+ response_text: The final text response from the agent (if successful)
505
+ metadata: Optional dict with additional context (tokens used, etc.)
506
+
507
+ Returns:
508
+ List of results from registered callbacks.
509
+ """
510
+ return await _trigger_callbacks(
511
+ "agent_run_end",
512
+ agent_name,
513
+ model_name,
514
+ session_id,
515
+ success,
516
+ error,
517
+ response_text,
518
+ metadata,
519
+ )
@@ -675,15 +675,21 @@ class RichConsoleRenderer:
675
675
  self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
676
676
 
677
677
  def _render_shell_line(self, msg: ShellLineMessage) -> None:
678
- """Render shell output line preserving ANSI codes."""
679
- from rich.text import Text
678
+ """Render shell output line preserving ANSI codes and carriage returns."""
679
+ import sys
680
680
 
681
- # Use Text.from_ansi() to parse ANSI codes into Rich styling
682
- # This preserves colors while still being safe
683
- text = Text.from_ansi(msg.line)
681
+ from rich.text import Text
684
682
 
685
- # Make all shell output dim to reduce visual noise
686
- 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")
687
693
 
688
694
  def _render_shell_output(self, msg: ShellOutputMessage) -> None:
689
695
  """Render shell command output - just a trailing newline for spinner separation.