code-puppy 0.0.375__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.
@@ -47,7 +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 on_agent_response_complete
50
+ from code_puppy.callbacks import (
51
+ on_agent_run_end,
52
+ on_agent_run_start,
53
+ )
51
54
 
52
55
  # Consolidated relative imports
53
56
  from code_puppy.config import (
@@ -404,35 +407,27 @@ class BaseAgent(ABC):
404
407
  total_tokens = 0
405
408
 
406
409
  # 1. Estimate tokens for system prompt / instructions
407
- # For Claude Code models, the full system prompt is prepended to the first
408
- # user message (already in message history), so we only count the short
409
- # 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.
410
413
  try:
411
- from code_puppy.model_utils import (
412
- get_antigravity_instructions,
413
- get_claude_code_instructions,
414
- is_antigravity_model,
415
- is_claude_code_model,
416
- )
414
+ from code_puppy.model_utils import prepare_prompt_for_model
417
415
 
418
416
  model_name = (
419
417
  self.get_model_name() if hasattr(self, "get_model_name") else ""
420
418
  )
421
- if is_claude_code_model(model_name):
422
- # For Claude Code models, only count the short fixed instructions
423
- # The full system prompt is already in the message history
424
- instructions = get_claude_code_instructions()
425
- total_tokens += self.estimate_token_count(instructions)
426
- elif is_antigravity_model(model_name):
427
- # For Antigravity models, only count the short fixed instructions
428
- # The full system prompt is already in the message history
429
- instructions = get_antigravity_instructions()
430
- total_tokens += self.estimate_token_count(instructions)
431
- else:
432
- # For other models, count the full system prompt
433
- system_prompt = self.get_full_system_prompt()
434
- if system_prompt:
435
- 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)
436
431
  except Exception:
437
432
  pass # If we can't get system prompt, skip it
438
433
 
@@ -1590,21 +1585,25 @@ class BaseAgent(ABC):
1590
1585
  if output_type is not None:
1591
1586
  pydantic_agent = self._create_agent_with_output_type(output_type)
1592
1587
 
1593
- # Handle claude-code, chatgpt-codex, and antigravity models: prepend system prompt to first user message
1594
- from code_puppy.model_utils import (
1595
- is_antigravity_model,
1596
- is_claude_code_model,
1597
- )
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
1598
1591
 
1599
- if is_claude_code_model(self.get_model_name()) or is_antigravity_model(
1600
- self.get_model_name()
1601
- ):
1602
- if len(self.get_message_history()) == 0:
1603
- system_prompt = self.get_full_system_prompt()
1604
- puppy_rules = self.load_puppy_rules()
1605
- if puppy_rules:
1606
- system_prompt += f"\n{puppy_rules}"
1607
- 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
1608
1607
 
1609
1608
  # Build combined prompt payload when attachments are provided.
1610
1609
  attachment_parts: List[Any] = []
@@ -1751,6 +1750,17 @@ class BaseAgent(ABC):
1751
1750
  # Create the task FIRST
1752
1751
  agent_task = asyncio.create_task(run_agent_task())
1753
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
+
1754
1764
  # Import shell process status helper
1755
1765
 
1756
1766
  loop = asyncio.get_running_loop()
@@ -1832,39 +1842,53 @@ class BaseAgent(ABC):
1832
1842
  except Exception:
1833
1843
  pass # Don't fail the run if cache update fails
1834
1844
 
1835
- # Trigger agent_response_complete callback for workflow orchestration
1836
- try:
1837
- # Extract the response text from the result
1838
- response_text = ""
1839
- if result is not None:
1840
- if hasattr(result, "data"):
1841
- response_text = str(result.data) if result.data else ""
1842
- elif hasattr(result, "output"):
1843
- response_text = str(result.output) if result.output else ""
1844
- else:
1845
- response_text = str(result)
1846
-
1847
- # Fire the callback - don't await to avoid blocking return
1848
- # Use asyncio.create_task to run it in background
1849
- asyncio.create_task(
1850
- on_agent_response_complete(
1851
- agent_name=self.name,
1852
- response_text=response_text,
1853
- session_id=group_id,
1854
- metadata={"model": self.get_model_name()},
1855
- )
1856
- )
1857
- except Exception:
1858
- pass # Don't fail the run if callback fails
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)
1859
1854
 
1855
+ _run_success = True
1856
+ _run_error = None
1860
1857
  return result
1861
1858
  except asyncio.CancelledError:
1859
+ _run_success = False
1860
+ _run_error = None # Cancellation is not an error
1861
+ _run_response_text = ""
1862
1862
  agent_task.cancel()
1863
1863
  except KeyboardInterrupt:
1864
- # Handle direct keyboard interrupt during await
1864
+ _run_success = False
1865
+ _run_error = None # User interrupt is not an error
1866
+ _run_response_text = ""
1865
1867
  if not agent_task.done():
1866
1868
  agent_task.cancel()
1869
+ except Exception as e:
1870
+ _run_success = False
1871
+ _run_error = e
1872
+ _run_response_text = ""
1873
+ raise
1867
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
+
1868
1892
  # Stop keyboard listener if it was started
1869
1893
  if key_listener_stop_event is not None:
1870
1894
  key_listener_stop_event.set()
code_puppy/callbacks.py CHANGED
@@ -25,7 +25,8 @@ PhaseType = Literal[
25
25
  "register_agents",
26
26
  "register_model_type",
27
27
  "get_model_system_prompt",
28
- "agent_response_complete",
28
+ "agent_run_start",
29
+ "agent_run_end",
29
30
  ]
30
31
  CallbackFunc = Callable[..., Any]
31
32
 
@@ -51,7 +52,8 @@ _callbacks: Dict[PhaseType, List[CallbackFunc]] = {
51
52
  "register_agents": [],
52
53
  "register_model_type": [],
53
54
  "get_model_system_prompt": [],
54
- "agent_response_complete": [],
55
+ "agent_run_start": [],
56
+ "agent_run_end": [],
55
57
  }
56
58
 
57
59
  logger = logging.getLogger(__name__)
@@ -446,26 +448,72 @@ def on_get_model_system_prompt(
446
448
  )
447
449
 
448
450
 
449
- async def on_agent_response_complete(
451
+ async def on_agent_run_start(
450
452
  agent_name: str,
451
- response_text: str,
453
+ model_name: str,
452
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,
453
484
  metadata: dict | None = None,
454
485
  ) -> List[Any]:
455
- """Trigger callbacks after an agent completes its full response.
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.
456
490
 
457
- This fires after all tool calls are resolved and the agent has finished.
458
491
  Useful for:
492
+ - Stopping background tasks (like token refresh heartbeats)
459
493
  - Workflow orchestration (like Ralph's autonomous loop)
460
494
  - Logging/analytics
495
+ - Resource cleanup
461
496
  - Detecting completion signals in responses
462
497
 
463
498
  Args:
464
- agent_name: Name of the agent that completed
465
- response_text: The final text response from the agent
499
+ agent_name: Name of the agent that finished
500
+ model_name: Name of the model that was used
466
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)
467
505
  metadata: Optional dict with additional context (tokens used, etc.)
506
+
507
+ Returns:
508
+ List of results from registered callbacks.
468
509
  """
469
510
  return await _trigger_callbacks(
470
- "agent_response_complete", agent_name, response_text, session_id, metadata
511
+ "agent_run_end",
512
+ agent_name,
513
+ model_name,
514
+ session_id,
515
+ success,
516
+ error,
517
+ response_text,
518
+ metadata,
471
519
  )
@@ -3,4 +3,23 @@ Claude Code OAuth Plugin for Code Puppy
3
3
 
4
4
  This plugin provides OAuth authentication for Claude Code and automatically
5
5
  adds available models to the extra_models.json configuration.
6
+
7
+ The plugin also includes a token refresh heartbeat for maintaining fresh
8
+ tokens during long-running agentic operations.
6
9
  """
10
+
11
+ from .token_refresh_heartbeat import (
12
+ TokenRefreshHeartbeat,
13
+ force_token_refresh,
14
+ get_current_heartbeat,
15
+ is_heartbeat_running,
16
+ token_refresh_heartbeat_context,
17
+ )
18
+
19
+ __all__ = [
20
+ "TokenRefreshHeartbeat",
21
+ "token_refresh_heartbeat_context",
22
+ "is_heartbeat_running",
23
+ "get_current_heartbeat",
24
+ "force_token_refresh",
25
+ ]
@@ -363,6 +363,78 @@ def _register_model_types() -> List[Dict[str, Any]]:
363
363
  return [{"type": "claude_code", "handler": _create_claude_code_model}]
364
364
 
365
365
 
366
+ # Global storage for the token refresh heartbeat
367
+ # Using a dict to allow multiple concurrent agent runs (keyed by session_id)
368
+ _active_heartbeats: Dict[str, Any] = {}
369
+
370
+
371
+ async def _on_agent_run_start(
372
+ agent_name: str,
373
+ model_name: str,
374
+ session_id: Optional[str] = None,
375
+ ) -> None:
376
+ """Start token refresh heartbeat for Claude Code OAuth models.
377
+
378
+ This callback is triggered when an agent run starts. If the model is a
379
+ Claude Code OAuth model, we start a background heartbeat to keep the
380
+ token fresh during long-running operations.
381
+ """
382
+ # Only start heartbeat for Claude Code models
383
+ if not model_name.startswith("claude-code"):
384
+ return
385
+
386
+ try:
387
+ from .token_refresh_heartbeat import TokenRefreshHeartbeat
388
+
389
+ heartbeat = TokenRefreshHeartbeat()
390
+ await heartbeat.start()
391
+
392
+ # Store heartbeat for cleanup, keyed by session_id
393
+ key = session_id or "default"
394
+ _active_heartbeats[key] = heartbeat
395
+ logger.debug(
396
+ "Started token refresh heartbeat for session %s (model: %s)",
397
+ key,
398
+ model_name,
399
+ )
400
+ except ImportError:
401
+ logger.debug("Token refresh heartbeat module not available")
402
+ except Exception as exc:
403
+ logger.debug("Failed to start token refresh heartbeat: %s", exc)
404
+
405
+
406
+ async def _on_agent_run_end(
407
+ agent_name: str,
408
+ model_name: str,
409
+ session_id: Optional[str] = None,
410
+ success: bool = True,
411
+ error: Optional[Exception] = None,
412
+ response_text: Optional[str] = None,
413
+ metadata: Optional[Dict[str, Any]] = None,
414
+ ) -> None:
415
+ """Stop token refresh heartbeat when agent run ends.
416
+
417
+ This callback is triggered when an agent run completes (success or failure).
418
+ We stop any heartbeat that was started for this session.
419
+ """
420
+ # We don't use response_text or metadata, just cleanup the heartbeat
421
+ key = session_id or "default"
422
+ heartbeat = _active_heartbeats.pop(key, None)
423
+
424
+ if heartbeat is not None:
425
+ try:
426
+ await heartbeat.stop()
427
+ logger.debug(
428
+ "Stopped token refresh heartbeat for session %s (refreshed %d times)",
429
+ key,
430
+ heartbeat.refresh_count,
431
+ )
432
+ except Exception as exc:
433
+ logger.debug("Error stopping token refresh heartbeat: %s", exc)
434
+
435
+
366
436
  register_callback("custom_command_help", _custom_help)
367
437
  register_callback("custom_command", _handle_custom_command)
368
438
  register_callback("register_model_type", _register_model_types)
439
+ register_callback("agent_run_start", _on_agent_run_start)
440
+ register_callback("agent_run_end", _on_agent_run_end)
@@ -0,0 +1,242 @@
1
+ """Token refresh heartbeat for long-running Claude Code OAuth sessions.
2
+
3
+ This module provides a background task that periodically checks and refreshes
4
+ Claude Code OAuth tokens during long-running agentic operations. This ensures
5
+ that tokens don't expire during extended streaming responses or tool processing.
6
+
7
+ Usage:
8
+ async with token_refresh_heartbeat_context():
9
+ # Long running agent operation
10
+ await agent.run(...)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import time
18
+ from contextlib import asynccontextmanager
19
+ from typing import Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Heartbeat interval in seconds - check token every 2 minutes
24
+ # This is frequent enough to catch expiring tokens before they cause issues
25
+ # but not so frequent as to spam the token endpoint
26
+ HEARTBEAT_INTERVAL_SECONDS = 120
27
+
28
+ # Minimum time between refresh attempts to avoid hammering the endpoint
29
+ MIN_REFRESH_INTERVAL_SECONDS = 60
30
+
31
+ # Global tracking of last refresh time to coordinate across heartbeats
32
+ _last_refresh_time: float = 0.0
33
+ _heartbeat_lock = asyncio.Lock()
34
+
35
+
36
+ class TokenRefreshHeartbeat:
37
+ """Background task that periodically refreshes Claude Code OAuth tokens.
38
+
39
+ This runs as an asyncio task during agent operations and checks if the
40
+ token needs refreshing at regular intervals.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ interval: float = HEARTBEAT_INTERVAL_SECONDS,
46
+ min_refresh_interval: float = MIN_REFRESH_INTERVAL_SECONDS,
47
+ ):
48
+ self._interval = interval
49
+ self._min_refresh_interval = min_refresh_interval
50
+ self._task: Optional[asyncio.Task] = None
51
+ self._stop_event = asyncio.Event()
52
+ self._refresh_count = 0
53
+
54
+ async def start(self) -> None:
55
+ """Start the heartbeat background task."""
56
+ if self._task is not None:
57
+ logger.debug("Heartbeat already running")
58
+ return
59
+
60
+ self._stop_event.clear()
61
+ self._task = asyncio.create_task(self._heartbeat_loop())
62
+ logger.debug("Token refresh heartbeat started")
63
+
64
+ async def stop(self) -> None:
65
+ """Stop the heartbeat background task."""
66
+ if self._task is None:
67
+ return
68
+
69
+ self._stop_event.set()
70
+ self._task.cancel()
71
+
72
+ try:
73
+ await self._task
74
+ except asyncio.CancelledError:
75
+ pass
76
+
77
+ self._task = None
78
+ logger.debug(
79
+ "Token refresh heartbeat stopped (refreshed %d times)",
80
+ self._refresh_count,
81
+ )
82
+
83
+ async def _heartbeat_loop(self) -> None:
84
+ """Main heartbeat loop that periodically checks token status."""
85
+ global _last_refresh_time
86
+
87
+ while not self._stop_event.is_set():
88
+ try:
89
+ # Wait for the interval or until stopped
90
+ try:
91
+ await asyncio.wait_for(
92
+ self._stop_event.wait(), timeout=self._interval
93
+ )
94
+ # If we got here, stop event was set
95
+ break
96
+ except asyncio.TimeoutError:
97
+ # Normal timeout - time to check token
98
+ pass
99
+
100
+ # Check if we should attempt refresh
101
+ async with _heartbeat_lock:
102
+ now = time.time()
103
+ if now - _last_refresh_time < self._min_refresh_interval:
104
+ logger.debug(
105
+ "Skipping refresh - last refresh was %.1f seconds ago",
106
+ now - _last_refresh_time,
107
+ )
108
+ continue
109
+
110
+ # Attempt the refresh
111
+ refreshed = await self._attempt_refresh()
112
+ if refreshed:
113
+ _last_refresh_time = now
114
+ self._refresh_count += 1
115
+
116
+ except asyncio.CancelledError:
117
+ break
118
+ except Exception as exc:
119
+ logger.debug("Error in heartbeat loop: %s", exc)
120
+ # Continue running - don't let errors kill the heartbeat
121
+ await asyncio.sleep(5) # Brief pause before retrying
122
+
123
+ async def _attempt_refresh(self) -> bool:
124
+ """Attempt to refresh the token if needed.
125
+
126
+ Returns:
127
+ True if a refresh was performed, False otherwise.
128
+ """
129
+ try:
130
+ # Import here to avoid circular imports
131
+ from .utils import (
132
+ is_token_expired,
133
+ load_stored_tokens,
134
+ refresh_access_token,
135
+ )
136
+
137
+ tokens = load_stored_tokens()
138
+ if not tokens:
139
+ logger.debug("No stored tokens found")
140
+ return False
141
+
142
+ if not is_token_expired(tokens):
143
+ logger.debug("Token not yet expired, skipping refresh")
144
+ return False
145
+
146
+ # Token is expiring soon, refresh it
147
+ logger.info("Heartbeat: Token expiring soon, refreshing proactively")
148
+ refreshed_token = refresh_access_token(force=False)
149
+
150
+ if refreshed_token:
151
+ logger.info("Heartbeat: Successfully refreshed token")
152
+ return True
153
+ else:
154
+ logger.warning("Heartbeat: Token refresh returned None")
155
+ return False
156
+
157
+ except Exception as exc:
158
+ logger.error("Heartbeat: Error during token refresh: %s", exc)
159
+ return False
160
+
161
+ @property
162
+ def refresh_count(self) -> int:
163
+ """Get the number of successful refreshes performed by this heartbeat."""
164
+ return self._refresh_count
165
+
166
+ @property
167
+ def is_running(self) -> bool:
168
+ """Check if the heartbeat is currently running."""
169
+ return self._task is not None and not self._task.done()
170
+
171
+
172
+ # Global heartbeat instance for the current session
173
+ _current_heartbeat: Optional[TokenRefreshHeartbeat] = None
174
+
175
+
176
+ @asynccontextmanager
177
+ async def token_refresh_heartbeat_context(
178
+ interval: float = HEARTBEAT_INTERVAL_SECONDS,
179
+ ):
180
+ """Context manager that runs token refresh heartbeat during its scope.
181
+
182
+ Use this around long-running agent operations to ensure tokens stay fresh.
183
+
184
+ Args:
185
+ interval: Seconds between heartbeat checks. Default is 2 minutes.
186
+
187
+ Example:
188
+ async with token_refresh_heartbeat_context():
189
+ result = await agent.run(prompt)
190
+ """
191
+ global _current_heartbeat
192
+
193
+ heartbeat = TokenRefreshHeartbeat(interval=interval)
194
+
195
+ try:
196
+ await heartbeat.start()
197
+ _current_heartbeat = heartbeat
198
+ yield heartbeat
199
+ finally:
200
+ await heartbeat.stop()
201
+ _current_heartbeat = None
202
+
203
+
204
+ def is_heartbeat_running() -> bool:
205
+ """Check if a token refresh heartbeat is currently active."""
206
+ return _current_heartbeat is not None and _current_heartbeat.is_running
207
+
208
+
209
+ def get_current_heartbeat() -> Optional[TokenRefreshHeartbeat]:
210
+ """Get the currently running heartbeat instance, if any."""
211
+ return _current_heartbeat
212
+
213
+
214
+ async def force_token_refresh() -> bool:
215
+ """Force an immediate token refresh.
216
+
217
+ This can be called from anywhere to trigger a token refresh,
218
+ regardless of whether a heartbeat is running.
219
+
220
+ Returns:
221
+ True if refresh was successful, False otherwise.
222
+ """
223
+ global _last_refresh_time
224
+
225
+ try:
226
+ from .utils import refresh_access_token
227
+
228
+ logger.info("Forcing token refresh")
229
+ refreshed_token = refresh_access_token(force=True)
230
+
231
+ if refreshed_token:
232
+ async with _heartbeat_lock:
233
+ _last_refresh_time = time.time()
234
+ logger.info("Force refresh successful")
235
+ return True
236
+ else:
237
+ logger.warning("Force refresh returned None")
238
+ return False
239
+
240
+ except Exception as exc:
241
+ logger.error("Force refresh error: %s", exc)
242
+ return False
@@ -17,46 +17,46 @@ logger = logging.getLogger(__name__)
17
17
 
18
18
  class RalphLoopController:
19
19
  """Controls the Ralph autonomous loop.
20
-
20
+
21
21
  Each iteration:
22
22
  1. Checks if there's work to do
23
23
  2. Invokes the ralph-orchestrator agent with a FRESH session
24
24
  3. Waits for completion
25
25
  4. Checks if all stories are done or if we should continue
26
26
  """
27
-
27
+
28
28
  def __init__(self, max_iterations: int = 10):
29
29
  self.max_iterations = max_iterations
30
30
  self.current_iteration = 0
31
31
  self.is_complete = False
32
32
  self.is_running = False
33
33
  self._stop_requested = False
34
-
34
+
35
35
  def request_stop(self) -> None:
36
36
  """Request the loop to stop after current iteration."""
37
37
  self._stop_requested = True
38
38
  emit_warning("🛑 Stop requested - will halt after current iteration")
39
-
39
+
40
40
  async def run(
41
41
  self,
42
42
  invoke_func: Callable[[str, str, Optional[str]], Awaitable[dict]],
43
43
  ) -> dict:
44
44
  """Run the Ralph loop until completion or max iterations.
45
-
45
+
46
46
  Args:
47
- invoke_func: Async function to invoke an agent.
47
+ invoke_func: Async function to invoke an agent.
48
48
  Signature: (agent_name, prompt, session_id) -> result_dict
49
49
  The result_dict should have 'response' and 'error' keys.
50
-
50
+
51
51
  Returns:
52
52
  dict with 'success', 'iterations', 'message' keys
53
53
  """
54
54
  self.is_running = True
55
55
  self.is_complete = False
56
56
  self._stop_requested = False
57
-
57
+
58
58
  manager = get_state_manager()
59
-
59
+
60
60
  # Pre-flight checks
61
61
  if not manager.prd_exists():
62
62
  self.is_running = False
@@ -65,7 +65,7 @@ class RalphLoopController:
65
65
  "iterations": 0,
66
66
  "message": "No prd.json found. Create one with /ralph prd first.",
67
67
  }
68
-
68
+
69
69
  if manager.all_stories_complete():
70
70
  self.is_running = False
71
71
  return {
@@ -73,68 +73,68 @@ class RalphLoopController:
73
73
  "iterations": 0,
74
74
  "message": "All stories already complete!",
75
75
  }
76
-
76
+
77
77
  prd = manager.read_prd()
78
- emit_info(f"🐺 Starting Ralph Loop")
78
+ emit_info("🐺 Starting Ralph Loop")
79
79
  emit_info(f"📋 Project: {prd.project if prd else 'Unknown'}")
80
80
  emit_info(f"📊 Progress: {prd.get_progress_summary() if prd else 'Unknown'}")
81
81
  emit_info(f"🔄 Max iterations: {self.max_iterations}")
82
82
  emit_info("─" * 50)
83
-
83
+
84
84
  try:
85
85
  for iteration in range(1, self.max_iterations + 1):
86
86
  self.current_iteration = iteration
87
-
87
+
88
88
  # Check for stop request
89
89
  if self._stop_requested:
90
90
  emit_warning(f"🛑 Stopped at iteration {iteration}")
91
91
  break
92
-
92
+
93
93
  # Check if already complete
94
94
  if manager.all_stories_complete():
95
95
  self.is_complete = True
96
96
  emit_success("🎉 All stories complete!")
97
97
  break
98
-
98
+
99
99
  # Get current story for logging
100
100
  story = manager.get_next_story()
101
101
  if story is None:
102
102
  self.is_complete = True
103
103
  emit_success("🎉 All stories complete!")
104
104
  break
105
-
106
- emit_info(f"\n{'='*60}")
105
+
106
+ emit_info(f"\n{'=' * 60}")
107
107
  emit_info(f"🐺 RALPH ITERATION {iteration} of {self.max_iterations}")
108
108
  emit_info(f"📌 Working on: [{story.id}] {story.title}")
109
- emit_info(f"{'='*60}\n")
110
-
109
+ emit_info(f"{'=' * 60}\n")
110
+
111
111
  # Build the prompt for this iteration
112
112
  iteration_prompt = self._build_iteration_prompt(story)
113
-
113
+
114
114
  # Invoke orchestrator with FRESH session (unique per iteration)
115
115
  session_id = f"ralph-iter-{iteration}"
116
-
116
+
117
117
  try:
118
118
  result = await invoke_func(
119
119
  "ralph-orchestrator",
120
120
  iteration_prompt,
121
121
  session_id,
122
122
  )
123
-
123
+
124
124
  response = result.get("response", "")
125
125
  error = result.get("error")
126
-
126
+
127
127
  if error:
128
128
  emit_error(f"Iteration {iteration} error: {error}")
129
129
  # Continue to next iteration despite error
130
130
  continue
131
-
131
+
132
132
  # Check for completion signal in response
133
133
  if response and "<promise>COMPLETE</promise>" in response:
134
134
  self.is_complete = True
135
135
  emit_success("🎉 Ralph signaled COMPLETE - all stories done!")
136
136
  break
137
-
137
+
138
138
  except asyncio.CancelledError:
139
139
  emit_warning(f"🛑 Iteration {iteration} cancelled")
140
140
  break
@@ -143,44 +143,48 @@ class RalphLoopController:
143
143
  logger.exception(f"Ralph iteration {iteration} failed")
144
144
  # Continue to next iteration
145
145
  continue
146
-
146
+
147
147
  # Brief pause between iterations
148
148
  await asyncio.sleep(1)
149
-
149
+
150
150
  else:
151
151
  # Loop completed without break (max iterations reached)
152
152
  emit_warning(f"⚠️ Reached max iterations ({self.max_iterations})")
153
-
153
+
154
154
  finally:
155
155
  self.is_running = False
156
-
156
+
157
157
  # Final status
158
158
  prd = manager.read_prd()
159
159
  final_progress = prd.get_progress_summary() if prd else "Unknown"
160
-
160
+
161
161
  return {
162
162
  "success": self.is_complete,
163
163
  "iterations": self.current_iteration,
164
164
  "message": f"Completed {self.current_iteration} iterations. {final_progress}",
165
165
  "all_complete": self.is_complete,
166
166
  }
167
-
167
+
168
168
  def _build_iteration_prompt(self, story) -> str:
169
169
  """Build the prompt for a single iteration."""
170
170
  # Find VERIFY criteria
171
- verify_criteria = [c for c in story.acceptance_criteria if c.startswith("VERIFY:")]
172
- other_criteria = [c for c in story.acceptance_criteria if not c.startswith("VERIFY:")]
173
-
171
+ verify_criteria = [
172
+ c for c in story.acceptance_criteria if c.startswith("VERIFY:")
173
+ ]
174
+ other_criteria = [
175
+ c for c in story.acceptance_criteria if not c.startswith("VERIFY:")
176
+ ]
177
+
174
178
  verify_section = ""
175
179
  if verify_criteria:
176
180
  verify_section = f"""
177
181
  ## MANDATORY VERIFICATION COMMANDS
178
182
  You MUST run these commands and they MUST succeed before marking complete:
179
- {chr(10).join(f' {c}' for c in verify_criteria)}
183
+ {chr(10).join(f" {c}" for c in verify_criteria)}
180
184
 
181
185
  If ANY verification fails, fix the code and re-run until it passes!
182
186
  """
183
-
187
+
184
188
  return f"""Execute ONE iteration of the Ralph loop.
185
189
 
186
190
  ## Current Story
@@ -189,7 +193,7 @@ If ANY verification fails, fix the code and re-run until it passes!
189
193
  - **Description:** {story.description}
190
194
 
191
195
  ## Acceptance Criteria (implement ALL of these):
192
- {chr(10).join(f' - {c}' for c in other_criteria)}
196
+ {chr(10).join(f" - {c}" for c in other_criteria)}
193
197
  {verify_section}
194
198
  ## Requires UI Verification: {story.has_ui_verification()}
195
199
  {"If yes, invoke qa-kitten to verify UI changes work correctly." if story.has_ui_verification() else ""}
@@ -227,18 +231,18 @@ async def run_ralph_loop(
227
231
  invoke_func: Optional[Callable] = None,
228
232
  ) -> dict:
229
233
  """Convenience function to run the Ralph loop.
230
-
234
+
231
235
  Args:
232
236
  max_iterations: Maximum number of iterations
233
237
  invoke_func: Function to invoke agents. If None, uses default.
234
-
238
+
235
239
  Returns:
236
240
  Result dict from the controller
237
241
  """
238
242
  if invoke_func is None:
239
243
  # Use the default agent invocation mechanism
240
244
  invoke_func = _default_invoke_agent
241
-
245
+
242
246
  controller = get_loop_controller(max_iterations)
243
247
  return await controller.run(invoke_func)
244
248
 
@@ -251,20 +255,20 @@ async def _default_invoke_agent(
251
255
  """Default agent invocation using code_puppy's agent system."""
252
256
  try:
253
257
  from code_puppy.agents import get_current_agent, load_agent, set_current_agent
254
-
258
+
255
259
  # Save current agent to restore later
256
260
  original_agent = get_current_agent()
257
-
261
+
258
262
  try:
259
263
  # Load the target agent
260
264
  target_agent = load_agent(agent_name)
261
265
  if target_agent is None:
262
266
  return {"response": None, "error": f"Agent '{agent_name}' not found"}
263
-
267
+
264
268
  # Run the agent with the prompt
265
269
  # Note: This creates a fresh run with no message history
266
270
  result = await target_agent.run_with_mcp(prompt)
267
-
271
+
268
272
  # Extract response text
269
273
  response_text = ""
270
274
  if result is not None:
@@ -272,14 +276,14 @@ async def _default_invoke_agent(
272
276
  response_text = str(result.data) if result.data else ""
273
277
  else:
274
278
  response_text = str(result)
275
-
279
+
276
280
  return {"response": response_text, "error": None}
277
-
281
+
278
282
  finally:
279
283
  # Restore original agent
280
284
  if original_agent:
281
285
  set_current_agent(original_agent.name)
282
-
286
+
283
287
  except Exception as e:
284
288
  logger.exception(f"Failed to invoke agent {agent_name}")
285
289
  return {"response": None, "error": str(e)}
@@ -5,7 +5,7 @@ This module registers all Ralph callbacks:
5
5
  - register_agents: PRD Generator, Converter, and Orchestrator agents
6
6
  - custom_command: /ralph slash commands
7
7
  - custom_command_help: Help entries for Ralph commands
8
- - agent_response_complete: Detect completion signal for loop termination
8
+ - agent_run_end: Detect completion signal for loop termination
9
9
  """
10
10
 
11
11
  import logging
@@ -83,13 +83,16 @@ def reset_ralph_completion() -> None:
83
83
  _ralph_last_session_id = None
84
84
 
85
85
 
86
- async def _on_agent_complete(
86
+ async def _on_agent_run_end(
87
87
  agent_name: str,
88
- response_text: str,
88
+ model_name: str,
89
89
  session_id: Optional[str] = None,
90
+ success: bool = True,
91
+ error: Optional[Exception] = None,
92
+ response_text: Optional[str] = None,
90
93
  metadata: Optional[dict] = None,
91
94
  ) -> None:
92
- """Handle agent response completion.
95
+ """Handle agent run completion.
93
96
 
94
97
  This detects the <promise>COMPLETE</promise> signal from the
95
98
  Ralph Orchestrator and sets the completion flag.
@@ -100,10 +103,14 @@ async def _on_agent_complete(
100
103
  if agent_name != "ralph-orchestrator":
101
104
  return
102
105
 
106
+ # Only process successful runs with response text
107
+ if not success or not response_text:
108
+ return
109
+
103
110
  logger.debug(f"Ralph plugin: orchestrator completed (session={session_id})")
104
111
 
105
112
  # Check for completion signal
106
- if response_text and "<promise>COMPLETE</promise>" in response_text:
113
+ if "<promise>COMPLETE</promise>" in response_text:
107
114
  _ralph_completion_detected = True
108
115
  _ralph_last_session_id = session_id
109
116
 
@@ -127,7 +134,7 @@ register_callback("custom_command", _handle_command)
127
134
  register_callback("custom_command_help", _provide_command_help)
128
135
 
129
136
  # Completion detection
130
- register_callback("agent_response_complete", _on_agent_complete)
137
+ register_callback("agent_run_end", _on_agent_run_end)
131
138
 
132
139
 
133
140
  logger.info("Ralph plugin: all callbacks registered successfully")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.375
3
+ Version: 0.0.376
4
4
  Summary: Code generation agent
5
5
  Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
6
6
  Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
@@ -1,6 +1,6 @@
1
1
  code_puppy/__init__.py,sha256=xMPewo9RNHb3yfFNIk5WCbv2cvSPtJOCgK2-GqLbNnU,373
2
2
  code_puppy/__main__.py,sha256=pDVssJOWP8A83iFkxMLY9YteHYat0EyWDQqMkKHpWp4,203
3
- code_puppy/callbacks.py,sha256=UyzEyuR0GPZFJ3td51m8JdzS8qLUYzigUsL4k3w6VnI,15466
3
+ code_puppy/callbacks.py,sha256=A7aqykjNVB2HorewzerCEb3YYnqeDIdQG_2wNTt1paE,16745
4
4
  code_puppy/chatgpt_codex_client.py,sha256=upMuAfOhMB7SEpVw4CU4GjgaeZ8X65ri3yNM-dnlmYA,12308
5
5
  code_puppy/claude_cache_client.py,sha256=GtwYrxcTe0pE-JGtl1ysR2qskfeE73_x4w7q_u-kR1k,24026
6
6
  code_puppy/cli_runner.py,sha256=w5CLKgQYYaT7My3Cga2StXYol-u6DBxNzzUuhhsfhsA,34952
@@ -45,7 +45,7 @@ code_puppy/agents/agent_qa_kitten.py,sha256=qvry-1u_CiXi8eRueHTax4OtqsS_mQrtXHsb
45
45
  code_puppy/agents/agent_security_auditor.py,sha256=SpiYNA0XAsIwBj7S2_EQPRslRUmF_-b89pIJyW7DYtY,12022
46
46
  code_puppy/agents/agent_terminal_qa.py,sha256=U-iyP7OBWdAmchW_oUU8k6asH2aignTMmgqqYDyf-ms,10343
47
47
  code_puppy/agents/agent_typescript_reviewer.py,sha256=vsnpp98xg6cIoFAEJrRTUM_i4wLEWGm5nJxs6fhHobM,10275
48
- code_puppy/agents/base_agent.py,sha256=dFmMtE2i0QLq2mwhjx4MHvz8h79U3pZLbY9regd379Y,75648
48
+ code_puppy/agents/base_agent.py,sha256=gZvACpqH8L2Wp2xjn_v5D4Azlt6yvGnQO6UApN8HCEo,76272
49
49
  code_puppy/agents/event_stream_handler.py,sha256=JttLZJpNADE5HXiXY-GZ6tpwaBeFRODcy34KiquPOvU,14952
50
50
  code_puppy/agents/json_agent.py,sha256=FtbZxO8mo563kvXgpgRM4b-c9VA3G3cty7r-O0nBZQk,5690
51
51
  code_puppy/agents/prompt_reviewer.py,sha256=JJrJ0m5q0Puxl8vFsyhAbY9ftU9n6c6UxEVdNct1E-Q,5558
@@ -169,10 +169,11 @@ code_puppy/plugins/chatgpt_oauth/test_plugin.py,sha256=oHX7Eb_Hb4rgRpOWdhtFp8Jj6
169
169
  code_puppy/plugins/chatgpt_oauth/utils.py,sha256=fzpsCQOv0kqPWmG5vNEV_GLSUrMQh8cF7tdIjSOt1Dc,16504
170
170
  code_puppy/plugins/claude_code_oauth/README.md,sha256=fOSDDzCdm2JCKjU5J82IRHIAhxYxl8_UmHo7uH4AbFo,5469
171
171
  code_puppy/plugins/claude_code_oauth/SETUP.md,sha256=DCNLkSU9nf86S1rsrIg8HBe87NZrF8YND8P4ettWeEM,3289
172
- code_puppy/plugins/claude_code_oauth/__init__.py,sha256=mCcOU-wM7LNCDjr-w-WLPzom8nTF1UNt4nqxGE6Rt0k,187
172
+ code_puppy/plugins/claude_code_oauth/__init__.py,sha256=DgYQ1zQy-Wf-tSphD4wYrvtztdr-Dubhf4AqsSi315I,659
173
173
  code_puppy/plugins/claude_code_oauth/config.py,sha256=DjGySCkvjSGZds6DYErLMAi3TItt8iSLGvyJN98nSEM,2013
174
- code_puppy/plugins/claude_code_oauth/register_callbacks.py,sha256=FIHPQFJdsICesZlxt-wg7IFr1SOeKP3G8J8GmvK6O64,12635
174
+ code_puppy/plugins/claude_code_oauth/register_callbacks.py,sha256=uvNA2wpSifeCD_o2CTENCuXQiq2ntk0uzQw8iAhZYng,15080
175
175
  code_puppy/plugins/claude_code_oauth/test_plugin.py,sha256=yQy4EeZl4bjrcog1d8BjknoDTRK75mRXXvkSQJYSSEM,9286
176
+ code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py,sha256=ErkrquUUTaqHkrLuPxTZymLkMOcju-wr5zAuO4fyN-s,7865
176
177
  code_puppy/plugins/claude_code_oauth/utils.py,sha256=2ioGG-4FCh4WdHrN2MJvWKbPWA-YVg_WTEeddc1xv4U,18557
177
178
  code_puppy/plugins/customizable_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
178
179
  code_puppy/plugins/customizable_commands/register_callbacks.py,sha256=zVMfIzr--hVn0IOXxIicbmgj2s-HZUgtrOc0NCDOnDw,5183
@@ -186,9 +187,9 @@ code_puppy/plugins/frontend_emitter/register_callbacks.py,sha256=3j7Emn3VeWSJSN9
186
187
  code_puppy/plugins/ralph/__init__.py,sha256=drPRgJ2LGLh8v_8dk7TJIklv-51XHjqW7SMfNTzwiGI,448
187
188
  code_puppy/plugins/ralph/agents.py,sha256=EQ2pvb8tb8aFfYZYtBuo9rBIvVp18XCieeK5PRvH3K4,11914
188
189
  code_puppy/plugins/ralph/commands.py,sha256=J1sVNrm4SBj_ujkO51ILWzdH5L3px6NPR_mn0mXD_FQ,6736
189
- code_puppy/plugins/ralph/loop_controller.py,sha256=lebabWFyeGPUL_6G0gANbjHUF11ZNsry4IytgwhkSsg,10497
190
+ code_puppy/plugins/ralph/loop_controller.py,sha256=uhEOKpmkxlfj5MK6_zVvSHH9D79JRx4RRkNtBkrBkNk,10107
190
191
  code_puppy/plugins/ralph/models.py,sha256=IrAJW85CgKA6yYSrWRn3VHx_65ukFkrasUhbVM1emF0,3983
191
- code_puppy/plugins/ralph/register_callbacks.py,sha256=ozhXyyb-GS-PQ3cRUMyDnLntwbZ1aVxjVwSExcHhgcE,4260
192
+ code_puppy/plugins/ralph/register_callbacks.py,sha256=_q1SILAYc8BHyWpXZoA8MOVsLCFnOvQ3vU8PzjfzZxc,4429
192
193
  code_puppy/plugins/ralph/state_manager.py,sha256=jv7bnQBiy_XQTj7Kfl3zBIMLV3XN5U-IS_U-fIdpHe0,10215
193
194
  code_puppy/plugins/ralph/tools.py,sha256=p767ZdjkeRmfWTqUYdFmXnKEHC15ek1Ho3tkLcjWb20,14391
194
195
  code_puppy/plugins/shell_safety/__init__.py,sha256=B-RYLWKlvrws9XCHG1Z99mBMC3VC394HAlMOhhCoGGI,243
@@ -224,10 +225,10 @@ code_puppy/tools/browser/chromium_terminal_manager.py,sha256=w1thQ_ACb6oV45L93TS
224
225
  code_puppy/tools/browser/terminal_command_tools.py,sha256=9byOZku-dwvTtCl532xt7Lumed_jTn0sLvUe_X75XCQ,19068
225
226
  code_puppy/tools/browser/terminal_screenshot_tools.py,sha256=J_21YO_495NvYgNFu9KQP6VYg2K_f8CtSdZuF94Yhnw,18448
226
227
  code_puppy/tools/browser/terminal_tools.py,sha256=F5LjVH3udSCFHmqC3O1UJLoLozZFZsEdX42jOmkqkW0,17853
227
- code_puppy-0.0.375.data/data/code_puppy/models.json,sha256=jAHRsCl3trysP4vU_k_ltA8GcFU2APd4lxFl8-4Jnvc,3243
228
- code_puppy-0.0.375.data/data/code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
229
- code_puppy-0.0.375.dist-info/METADATA,sha256=G1WxQE4CL8DRjJgD1MNtPCys-eN8KsL6A02CRLvvIzg,27604
230
- code_puppy-0.0.375.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
231
- code_puppy-0.0.375.dist-info/entry_points.txt,sha256=Tp4eQC99WY3HOKd3sdvb22vZODRq0XkZVNpXOag_KdI,91
232
- code_puppy-0.0.375.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
233
- code_puppy-0.0.375.dist-info/RECORD,,
228
+ code_puppy-0.0.376.data/data/code_puppy/models.json,sha256=jAHRsCl3trysP4vU_k_ltA8GcFU2APd4lxFl8-4Jnvc,3243
229
+ code_puppy-0.0.376.data/data/code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
230
+ code_puppy-0.0.376.dist-info/METADATA,sha256=8zBZ-_vl5I-bVjco6ZxTH1Vsiz7NQQO1AqtYAiisweA,27604
231
+ code_puppy-0.0.376.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
232
+ code_puppy-0.0.376.dist-info/entry_points.txt,sha256=Tp4eQC99WY3HOKd3sdvb22vZODRq0XkZVNpXOag_KdI,91
233
+ code_puppy-0.0.376.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
234
+ code_puppy-0.0.376.dist-info/RECORD,,