code-puppy 0.0.354__py3-none-any.whl → 0.0.355__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 (37) hide show
  1. code_puppy/agents/__init__.py +2 -0
  2. code_puppy/agents/event_stream_handler.py +74 -1
  3. code_puppy/agents/subagent_stream_handler.py +276 -0
  4. code_puppy/api/__init__.py +13 -0
  5. code_puppy/api/app.py +92 -0
  6. code_puppy/api/main.py +21 -0
  7. code_puppy/api/pty_manager.py +446 -0
  8. code_puppy/api/routers/__init__.py +12 -0
  9. code_puppy/api/routers/agents.py +36 -0
  10. code_puppy/api/routers/commands.py +198 -0
  11. code_puppy/api/routers/config.py +74 -0
  12. code_puppy/api/routers/sessions.py +191 -0
  13. code_puppy/api/templates/terminal.html +361 -0
  14. code_puppy/api/websocket.py +154 -0
  15. code_puppy/callbacks.py +73 -0
  16. code_puppy/command_line/core_commands.py +85 -0
  17. code_puppy/config.py +63 -0
  18. code_puppy/messaging/__init__.py +15 -0
  19. code_puppy/messaging/messages.py +27 -0
  20. code_puppy/messaging/rich_renderer.py +34 -0
  21. code_puppy/messaging/spinner/__init__.py +20 -2
  22. code_puppy/messaging/subagent_console.py +461 -0
  23. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  24. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  25. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  26. code_puppy/status_display.py +6 -2
  27. code_puppy/tools/agent_tools.py +53 -49
  28. code_puppy/tools/common.py +176 -1
  29. code_puppy/tools/display.py +6 -1
  30. code_puppy/tools/subagent_context.py +158 -0
  31. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/METADATA +4 -3
  32. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/RECORD +37 -20
  33. {code_puppy-0.0.354.data → code_puppy-0.0.355.data}/data/code_puppy/models.json +0 -0
  34. {code_puppy-0.0.354.data → code_puppy-0.0.355.data}/data/code_puppy/models_dev_api.json +0 -0
  35. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/WHEEL +0 -0
  36. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/entry_points.txt +0 -0
  37. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/licenses/LICENSE +0 -0
@@ -12,6 +12,7 @@ from .agent_manager import (
12
12
  refresh_agents,
13
13
  set_current_agent,
14
14
  )
15
+ from .subagent_stream_handler import subagent_stream_handler
15
16
 
16
17
  __all__ = [
17
18
  "get_available_agents",
@@ -20,4 +21,5 @@ __all__ = [
20
21
  "load_agent",
21
22
  "get_agent_descriptions",
22
23
  "refresh_agents",
24
+ "subagent_stream_handler",
23
25
  ]
@@ -1,5 +1,7 @@
1
1
  """Event stream handler for processing streaming events from agent runs."""
2
2
 
3
+ import asyncio
4
+ import logging
3
5
  from collections.abc import AsyncIterable
4
6
  from typing import Any, Optional
5
7
 
@@ -16,8 +18,35 @@ from rich.console import Console
16
18
  from rich.markup import escape
17
19
  from rich.text import Text
18
20
 
19
- from code_puppy.config import get_banner_color
21
+ from code_puppy.config import get_banner_color, get_subagent_verbose
20
22
  from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners
23
+ from code_puppy.tools.subagent_context import is_subagent
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _fire_stream_event(event_type: str, event_data: Any) -> None:
29
+ """Fire a stream event callback asynchronously (non-blocking).
30
+
31
+ Args:
32
+ event_type: Type of the event (e.g., 'part_start', 'part_delta', 'part_end')
33
+ event_data: Data associated with the event
34
+ """
35
+ try:
36
+ from code_puppy import callbacks
37
+ from code_puppy.messaging import get_session_context
38
+
39
+ agent_session_id = get_session_context()
40
+
41
+ # Use create_task to fire callback without blocking
42
+ asyncio.create_task(
43
+ callbacks.on_stream_event(event_type, event_data, agent_session_id)
44
+ )
45
+ except ImportError:
46
+ logger.debug("callbacks or messaging module not available for stream event")
47
+ except Exception as e:
48
+ logger.debug(f"Error firing stream event callback: {e}")
49
+
21
50
 
22
51
  # Module-level console for streaming output
23
52
  # Set via set_streaming_console() to share console with spinner
@@ -47,6 +76,15 @@ def get_streaming_console() -> Console:
47
76
  return Console()
48
77
 
49
78
 
79
+ def _should_suppress_output() -> bool:
80
+ """Check if sub-agent output should be suppressed.
81
+
82
+ Returns:
83
+ True if we're in a sub-agent context and verbose mode is disabled.
84
+ """
85
+ return is_subagent() and not get_subagent_verbose()
86
+
87
+
50
88
  async def event_stream_handler(
51
89
  ctx: RunContext,
52
90
  events: AsyncIterable[Any],
@@ -60,6 +98,12 @@ async def event_stream_handler(
60
98
  ctx: The run context.
61
99
  events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
62
100
  """
101
+ # If we're in a sub-agent and verbose mode is disabled, silently consume events
102
+ if _should_suppress_output():
103
+ async for _ in events:
104
+ pass # Just consume events without rendering
105
+ return
106
+
63
107
  import time
64
108
 
65
109
  from termflow import Parser as TermflowParser
@@ -121,6 +165,16 @@ async def event_stream_handler(
121
165
  async for event in events:
122
166
  # PartStartEvent - register the part but defer banner until content arrives
123
167
  if isinstance(event, PartStartEvent):
168
+ # Fire stream event callback for part_start
169
+ _fire_stream_event(
170
+ "part_start",
171
+ {
172
+ "index": event.index,
173
+ "part_type": type(event.part).__name__,
174
+ "part": event.part,
175
+ },
176
+ )
177
+
124
178
  part = event.part
125
179
  if isinstance(part, ThinkingPart):
126
180
  streaming_parts.add(event.index)
@@ -156,6 +210,16 @@ async def event_stream_handler(
156
210
 
157
211
  # PartDeltaEvent - stream the content as it arrives
158
212
  elif isinstance(event, PartDeltaEvent):
213
+ # Fire stream event callback for part_delta
214
+ _fire_stream_event(
215
+ "part_delta",
216
+ {
217
+ "index": event.index,
218
+ "delta_type": type(event.delta).__name__,
219
+ "delta": event.delta,
220
+ },
221
+ )
222
+
159
223
  if event.index in streaming_parts:
160
224
  delta = event.delta
161
225
  if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
@@ -208,6 +272,15 @@ async def event_stream_handler(
208
272
 
209
273
  # PartEndEvent - finish the streaming with a newline
210
274
  elif isinstance(event, PartEndEvent):
275
+ # Fire stream event callback for part_end
276
+ _fire_stream_event(
277
+ "part_end",
278
+ {
279
+ "index": event.index,
280
+ "next_part_kind": getattr(event, "next_part_kind", None),
281
+ },
282
+ )
283
+
211
284
  if event.index in streaming_parts:
212
285
  # For text parts, finalize termflow rendering
213
286
  if event.index in text_parts:
@@ -0,0 +1,276 @@
1
+ """Silenced event stream handler for sub-agents.
2
+
3
+ This handler suppresses all console output but still:
4
+ - Updates SubAgentConsoleManager with status/metrics
5
+ - Fires stream_event callbacks for the frontend emitter plugin
6
+ - Tracks tool calls, tokens, and status changes
7
+
8
+ Usage:
9
+ >>> from code_puppy.agents.subagent_stream_handler import subagent_stream_handler
10
+ >>> # In agent run:
11
+ >>> await subagent_stream_handler(ctx, events, session_id="my-session-123")
12
+ """
13
+
14
+ import asyncio
15
+ import logging
16
+ from collections.abc import AsyncIterable
17
+ from typing import Any, Optional
18
+
19
+ from pydantic_ai import PartDeltaEvent, PartEndEvent, PartStartEvent, RunContext
20
+ from pydantic_ai.messages import (
21
+ TextPart,
22
+ TextPartDelta,
23
+ ThinkingPart,
24
+ ThinkingPartDelta,
25
+ ToolCallPart,
26
+ ToolCallPartDelta,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # =============================================================================
33
+ # Callback Helper
34
+ # =============================================================================
35
+
36
+
37
+ def _fire_callback(event_type: str, event_data: Any, session_id: Optional[str]) -> None:
38
+ """Fire stream_event callback non-blocking.
39
+
40
+ Schedules the callback to run asynchronously without waiting for it.
41
+ Silently ignores errors if no event loop is running or if the callback
42
+ system is unavailable.
43
+
44
+ Args:
45
+ event_type: Type of the event ('part_start', 'part_delta', 'part_end')
46
+ event_data: Dictionary containing event-specific data
47
+ session_id: Optional session ID for the sub-agent
48
+ """
49
+ try:
50
+ from code_puppy import callbacks
51
+
52
+ loop = asyncio.get_running_loop()
53
+ loop.create_task(callbacks.on_stream_event(event_type, event_data, session_id))
54
+ except RuntimeError:
55
+ # No event loop running - this can happen during shutdown
56
+ logger.debug("No event loop available for stream event callback")
57
+ except ImportError:
58
+ # Callbacks module not available
59
+ logger.debug("Callbacks module not available for stream event")
60
+ except Exception as e:
61
+ # Don't let callback errors break the stream handler
62
+ logger.debug(f"Error firing stream event callback: {e}")
63
+
64
+
65
+ # =============================================================================
66
+ # Token Estimation
67
+ # =============================================================================
68
+
69
+
70
+ def _estimate_tokens(content: str) -> int:
71
+ """Estimate token count from content string.
72
+
73
+ Uses a rough heuristic: ~4 characters per token for English text.
74
+ This is a ballpark estimate - actual tokenization varies by model.
75
+
76
+ Args:
77
+ content: The text content to estimate tokens for
78
+
79
+ Returns:
80
+ Estimated token count (minimum 1 for non-empty content)
81
+ """
82
+ if not content:
83
+ return 0
84
+ # Rough estimate: 4 chars = 1 token, minimum 1 for any content
85
+ return max(1, len(content) // 4)
86
+
87
+
88
+ # =============================================================================
89
+ # Main Handler
90
+ # =============================================================================
91
+
92
+
93
+ async def subagent_stream_handler(
94
+ ctx: RunContext,
95
+ events: AsyncIterable[Any],
96
+ session_id: Optional[str] = None,
97
+ ) -> None:
98
+ """Silent event stream handler for sub-agents.
99
+
100
+ Processes streaming events without producing any console output.
101
+ Updates the SubAgentConsoleManager with status and metrics, and fires
102
+ stream_event callbacks for any registered listeners.
103
+
104
+ Args:
105
+ ctx: The pydantic-ai run context
106
+ events: Async iterable of streaming events (PartStartEvent,
107
+ PartDeltaEvent, PartEndEvent)
108
+ session_id: Session ID of the sub-agent for console manager updates.
109
+ If None, falls back to get_session_context().
110
+ """
111
+ # Late import to avoid circular dependencies
112
+ from code_puppy.messaging import get_session_context
113
+ from code_puppy.messaging.subagent_console import SubAgentConsoleManager
114
+
115
+ manager = SubAgentConsoleManager.get_instance()
116
+
117
+ # Resolve session_id, falling back to context if not provided
118
+ effective_session_id = session_id or get_session_context()
119
+
120
+ # Metrics tracking
121
+ token_count = 0
122
+ tool_call_count = 0
123
+ active_tool_parts: set[int] = set() # Track active tool call indices
124
+
125
+ async for event in events:
126
+ try:
127
+ await _handle_event(
128
+ event=event,
129
+ manager=manager,
130
+ session_id=effective_session_id,
131
+ token_count=token_count,
132
+ tool_call_count=tool_call_count,
133
+ active_tool_parts=active_tool_parts,
134
+ )
135
+
136
+ # Update metrics from returned values
137
+ # (we need to track these at this level since they're modified in _handle_event)
138
+ if isinstance(event, PartStartEvent):
139
+ if isinstance(event.part, ToolCallPart):
140
+ tool_call_count += 1
141
+ active_tool_parts.add(event.index)
142
+
143
+ elif isinstance(event, PartDeltaEvent):
144
+ delta = event.delta
145
+ if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
146
+ if delta.content_delta:
147
+ token_count += _estimate_tokens(delta.content_delta)
148
+
149
+ elif isinstance(event, PartEndEvent):
150
+ active_tool_parts.discard(event.index)
151
+
152
+ except Exception as e:
153
+ # Log but don't crash on event handling errors
154
+ logger.debug(f"Error handling stream event: {e}")
155
+ continue
156
+
157
+
158
+ async def _handle_event(
159
+ event: Any,
160
+ manager: Any, # SubAgentConsoleManager
161
+ session_id: Optional[str],
162
+ token_count: int,
163
+ tool_call_count: int,
164
+ active_tool_parts: set[int],
165
+ ) -> None:
166
+ """Handle a single streaming event.
167
+
168
+ Updates the console manager and fires callbacks for each event type.
169
+
170
+ Args:
171
+ event: The streaming event to handle
172
+ manager: SubAgentConsoleManager instance
173
+ session_id: Session ID for updates
174
+ token_count: Current token count
175
+ tool_call_count: Current tool call count
176
+ active_tool_parts: Set of active tool call indices
177
+ """
178
+ if session_id is None:
179
+ # Can't update manager without session_id
180
+ logger.debug("No session_id available for stream event")
181
+ return
182
+
183
+ # -------------------------------------------------------------------------
184
+ # PartStartEvent - Track new parts and update status
185
+ # -------------------------------------------------------------------------
186
+ if isinstance(event, PartStartEvent):
187
+ part = event.part
188
+ event_data = {
189
+ "index": event.index,
190
+ "part_type": type(part).__name__,
191
+ }
192
+
193
+ if isinstance(part, ThinkingPart):
194
+ manager.update_agent(session_id, status="thinking")
195
+ event_data["content"] = getattr(part, "content", None)
196
+
197
+ elif isinstance(part, TextPart):
198
+ manager.update_agent(session_id, status="running")
199
+ event_data["content"] = getattr(part, "content", None)
200
+
201
+ elif isinstance(part, ToolCallPart):
202
+ # tool_call_count is updated in the main handler
203
+ manager.update_agent(
204
+ session_id,
205
+ status="tool_calling",
206
+ tool_call_count=tool_call_count + 1, # +1 for this new one
207
+ current_tool=part.tool_name,
208
+ )
209
+ event_data["tool_name"] = part.tool_name
210
+ event_data["tool_call_id"] = getattr(part, "tool_call_id", None)
211
+
212
+ _fire_callback("part_start", event_data, session_id)
213
+
214
+ # -------------------------------------------------------------------------
215
+ # PartDeltaEvent - Track content deltas and update metrics
216
+ # -------------------------------------------------------------------------
217
+ elif isinstance(event, PartDeltaEvent):
218
+ delta = event.delta
219
+ event_data = {
220
+ "index": event.index,
221
+ "delta_type": type(delta).__name__,
222
+ }
223
+
224
+ if isinstance(delta, TextPartDelta):
225
+ content_delta = delta.content_delta
226
+ if content_delta:
227
+ # Token count is updated in main handler
228
+ new_token_count = token_count + _estimate_tokens(content_delta)
229
+ manager.update_agent(session_id, token_count=new_token_count)
230
+ event_data["content_delta"] = content_delta
231
+
232
+ elif isinstance(delta, ThinkingPartDelta):
233
+ content_delta = delta.content_delta
234
+ if content_delta:
235
+ new_token_count = token_count + _estimate_tokens(content_delta)
236
+ manager.update_agent(session_id, token_count=new_token_count)
237
+ event_data["content_delta"] = content_delta
238
+
239
+ elif isinstance(delta, ToolCallPartDelta):
240
+ # Tool call deltas might have partial args
241
+ event_data["args_delta"] = getattr(delta, "args_delta", None)
242
+ event_data["tool_name_delta"] = getattr(delta, "tool_name_delta", None)
243
+
244
+ _fire_callback("part_delta", event_data, session_id)
245
+
246
+ # -------------------------------------------------------------------------
247
+ # PartEndEvent - Track part completion and update status
248
+ # -------------------------------------------------------------------------
249
+ elif isinstance(event, PartEndEvent):
250
+ event_data = {
251
+ "index": event.index,
252
+ "next_part_kind": getattr(event, "next_part_kind", None),
253
+ }
254
+
255
+ # If this was a tool call part ending, check if we should reset status
256
+ if event.index in active_tool_parts:
257
+ # Remove this index from active parts (done in main handler)
258
+ # If no more active tool parts after removal, reset to running
259
+ remaining_active = active_tool_parts - {event.index}
260
+ if not remaining_active:
261
+ manager.update_agent(
262
+ session_id,
263
+ current_tool=None,
264
+ status="running",
265
+ )
266
+
267
+ _fire_callback("part_end", event_data, session_id)
268
+
269
+
270
+ # =============================================================================
271
+ # Exports
272
+ # =============================================================================
273
+
274
+ __all__ = [
275
+ "subagent_stream_handler",
276
+ ]
@@ -0,0 +1,13 @@
1
+ """Code Puppy REST API module.
2
+
3
+ This module provides a FastAPI-based REST API for Code Puppy configuration,
4
+ sessions, commands, and real-time WebSocket communication.
5
+
6
+ Exports:
7
+ create_app: Factory function to create the FastAPI application
8
+ main: Entry point to run the server
9
+ """
10
+
11
+ from code_puppy.api.app import create_app
12
+
13
+ __all__ = ["create_app"]
code_puppy/api/app.py ADDED
@@ -0,0 +1,92 @@
1
+ """FastAPI application factory for Code Puppy API."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse, HTMLResponse
8
+
9
+
10
+ def create_app() -> FastAPI:
11
+ """Create and configure the FastAPI application."""
12
+ app = FastAPI(
13
+ title="Code Puppy API",
14
+ description="REST API and Interactive Terminal for Code Puppy",
15
+ version="1.0.0",
16
+ docs_url="/docs",
17
+ redoc_url="/redoc",
18
+ )
19
+
20
+ # CORS middleware for frontend access
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"], # Local/trusted
24
+ allow_credentials=True,
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+ # Include routers
30
+ from code_puppy.api.routers import agents, commands, config, sessions
31
+
32
+ app.include_router(config.router, prefix="/api/config", tags=["config"])
33
+ app.include_router(commands.router, prefix="/api/commands", tags=["commands"])
34
+ app.include_router(sessions.router, prefix="/api/sessions", tags=["sessions"])
35
+ app.include_router(agents.router, prefix="/api/agents", tags=["agents"])
36
+
37
+ # WebSocket endpoints (events + terminal)
38
+ from code_puppy.api.websocket import setup_websocket
39
+
40
+ setup_websocket(app)
41
+
42
+ # Templates directory
43
+ templates_dir = Path(__file__).parent / "templates"
44
+
45
+ @app.get("/")
46
+ async def root():
47
+ """Landing page with links to terminal and docs."""
48
+ return HTMLResponse(
49
+ content="""
50
+ <!DOCTYPE html>
51
+ <html>
52
+ <head>
53
+ <title>Code Puppy 🐶</title>
54
+ <script src="https://cdn.tailwindcss.com"></script>
55
+ </head>
56
+ <body class="bg-gray-900 text-white min-h-screen flex items-center justify-center">
57
+ <div class="text-center">
58
+ <h1 class="text-6xl mb-4">🐶</h1>
59
+ <h2 class="text-3xl font-bold mb-8">Code Puppy</h2>
60
+ <div class="space-x-4">
61
+ <a href="/terminal" class="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg text-lg font-semibold">
62
+ Open Terminal
63
+ </a>
64
+ <a href="/docs" class="px-6 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-lg">
65
+ API Docs
66
+ </a>
67
+ </div>
68
+ <p class="mt-8 text-gray-400">
69
+ WebSocket: ws://localhost:8765/ws/terminal
70
+ </p>
71
+ </div>
72
+ </body>
73
+ </html>
74
+ """
75
+ )
76
+
77
+ @app.get("/terminal")
78
+ async def terminal_page():
79
+ """Serve the interactive terminal page."""
80
+ html_file = templates_dir / "terminal.html"
81
+ if html_file.exists():
82
+ return FileResponse(html_file, media_type="text/html")
83
+ return HTMLResponse(
84
+ content="<h1>Terminal template not found</h1>",
85
+ status_code=404,
86
+ )
87
+
88
+ @app.get("/health")
89
+ async def health():
90
+ return {"status": "healthy"}
91
+
92
+ return app
code_puppy/api/main.py ADDED
@@ -0,0 +1,21 @@
1
+ """Entry point for running the FastAPI server."""
2
+
3
+ import uvicorn
4
+
5
+ from code_puppy.api.app import create_app
6
+
7
+ app = create_app()
8
+
9
+
10
+ def main(host: str = "127.0.0.1", port: int = 8765) -> None:
11
+ """Run the FastAPI server.
12
+
13
+ Args:
14
+ host: The host address to bind to. Defaults to localhost.
15
+ port: The port number to listen on. Defaults to 8765.
16
+ """
17
+ uvicorn.run(app, host=host, port=port)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()