code-puppy 0.0.353__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 (49) hide show
  1. code_puppy/agents/__init__.py +2 -0
  2. code_puppy/agents/agent_manager.py +49 -0
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_planning.py +1 -0
  5. code_puppy/agents/event_stream_handler.py +74 -1
  6. code_puppy/agents/pack/__init__.py +34 -0
  7. code_puppy/agents/pack/bloodhound.py +304 -0
  8. code_puppy/agents/pack/husky.py +321 -0
  9. code_puppy/agents/pack/retriever.py +393 -0
  10. code_puppy/agents/pack/shepherd.py +348 -0
  11. code_puppy/agents/pack/terrier.py +287 -0
  12. code_puppy/agents/pack/watchdog.py +367 -0
  13. code_puppy/agents/subagent_stream_handler.py +276 -0
  14. code_puppy/api/__init__.py +13 -0
  15. code_puppy/api/app.py +92 -0
  16. code_puppy/api/main.py +21 -0
  17. code_puppy/api/pty_manager.py +446 -0
  18. code_puppy/api/routers/__init__.py +12 -0
  19. code_puppy/api/routers/agents.py +36 -0
  20. code_puppy/api/routers/commands.py +198 -0
  21. code_puppy/api/routers/config.py +74 -0
  22. code_puppy/api/routers/sessions.py +191 -0
  23. code_puppy/api/templates/terminal.html +361 -0
  24. code_puppy/api/websocket.py +154 -0
  25. code_puppy/callbacks.py +73 -0
  26. code_puppy/command_line/core_commands.py +85 -0
  27. code_puppy/config.py +63 -0
  28. code_puppy/messaging/__init__.py +15 -0
  29. code_puppy/messaging/messages.py +27 -0
  30. code_puppy/messaging/queue_console.py +1 -1
  31. code_puppy/messaging/rich_renderer.py +34 -0
  32. code_puppy/messaging/spinner/__init__.py +20 -2
  33. code_puppy/messaging/subagent_console.py +461 -0
  34. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  35. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  36. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  37. code_puppy/status_display.py +6 -2
  38. code_puppy/tools/agent_tools.py +56 -50
  39. code_puppy/tools/browser/vqa_agent.py +1 -1
  40. code_puppy/tools/common.py +176 -1
  41. code_puppy/tools/display.py +6 -1
  42. code_puppy/tools/subagent_context.py +158 -0
  43. {code_puppy-0.0.353.dist-info → code_puppy-0.0.355.dist-info}/METADATA +4 -3
  44. {code_puppy-0.0.353.dist-info → code_puppy-0.0.355.dist-info}/RECORD +49 -24
  45. {code_puppy-0.0.353.data → code_puppy-0.0.355.data}/data/code_puppy/models.json +0 -0
  46. {code_puppy-0.0.353.data → code_puppy-0.0.355.data}/data/code_puppy/models_dev_api.json +0 -0
  47. {code_puppy-0.0.353.dist-info → code_puppy-0.0.355.dist-info}/WHEEL +0 -0
  48. {code_puppy-0.0.353.dist-info → code_puppy-0.0.355.dist-info}/entry_points.txt +0 -0
  49. {code_puppy-0.0.353.dist-info → code_puppy-0.0.355.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,261 @@
1
+ """Callback registration for frontend event emission.
2
+
3
+ This module registers callbacks for various agent events and emits them
4
+ to subscribed WebSocket handlers via the emitter module.
5
+ """
6
+
7
+ import logging
8
+ import time
9
+ from typing import Any, Dict, Optional
10
+
11
+ from code_puppy.callbacks import register_callback
12
+ from code_puppy.plugins.frontend_emitter.emitter import emit_event
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ async def on_pre_tool_call(
18
+ tool_name: str, tool_args: Dict[str, Any], context: Any = None
19
+ ) -> None:
20
+ """Emit an event when a tool call starts.
21
+
22
+ Args:
23
+ tool_name: Name of the tool being called
24
+ tool_args: Arguments being passed to the tool
25
+ context: Optional context data for the tool call
26
+ """
27
+ try:
28
+ emit_event(
29
+ "tool_call_start",
30
+ {
31
+ "tool_name": tool_name,
32
+ "tool_args": _sanitize_args(tool_args),
33
+ "start_time": time.time(),
34
+ },
35
+ )
36
+ logger.debug(f"Emitted tool_call_start for {tool_name}")
37
+ except Exception as e:
38
+ logger.error(f"Failed to emit pre_tool_call event: {e}")
39
+
40
+
41
+ async def on_post_tool_call(
42
+ tool_name: str,
43
+ tool_args: Dict[str, Any],
44
+ result: Any,
45
+ duration_ms: float,
46
+ context: Any = None,
47
+ ) -> None:
48
+ """Emit an event when a tool call completes.
49
+
50
+ Args:
51
+ tool_name: Name of the tool that was called
52
+ tool_args: Arguments that were passed to the tool
53
+ result: The result returned by the tool
54
+ duration_ms: Execution time in milliseconds
55
+ context: Optional context data for the tool call
56
+ """
57
+ try:
58
+ emit_event(
59
+ "tool_call_complete",
60
+ {
61
+ "tool_name": tool_name,
62
+ "tool_args": _sanitize_args(tool_args),
63
+ "duration_ms": duration_ms,
64
+ "success": _is_successful_result(result),
65
+ "result_summary": _summarize_result(result),
66
+ },
67
+ )
68
+ logger.debug(
69
+ f"Emitted tool_call_complete for {tool_name} ({duration_ms:.2f}ms)"
70
+ )
71
+ except Exception as e:
72
+ logger.error(f"Failed to emit post_tool_call event: {e}")
73
+
74
+
75
+ async def on_stream_event(
76
+ event_type: str, event_data: Any, agent_session_id: Optional[str] = None
77
+ ) -> None:
78
+ """Emit streaming events from the agent.
79
+
80
+ Args:
81
+ event_type: Type of the streaming event
82
+ event_data: Data associated with the event
83
+ agent_session_id: Optional session ID of the agent emitting the event
84
+ """
85
+ try:
86
+ emit_event(
87
+ "stream_event",
88
+ {
89
+ "event_type": event_type,
90
+ "event_data": _sanitize_event_data(event_data),
91
+ "agent_session_id": agent_session_id,
92
+ },
93
+ )
94
+ logger.debug(f"Emitted stream_event: {event_type}")
95
+ except Exception as e:
96
+ logger.error(f"Failed to emit stream_event: {e}")
97
+
98
+
99
+ async def on_invoke_agent(*args: Any, **kwargs: Any) -> None:
100
+ """Emit an event when an agent is invoked.
101
+
102
+ Args:
103
+ *args: Positional arguments from the invoke_agent callback
104
+ **kwargs: Keyword arguments from the invoke_agent callback
105
+ """
106
+ try:
107
+ # Extract relevant info from args/kwargs
108
+ agent_info = {
109
+ "agent_name": kwargs.get("agent_name") or (args[0] if args else None),
110
+ "session_id": kwargs.get("session_id"),
111
+ "prompt_preview": _truncate_string(
112
+ kwargs.get("prompt") or (args[1] if len(args) > 1 else None),
113
+ max_length=200,
114
+ ),
115
+ }
116
+ emit_event("agent_invoked", agent_info)
117
+ logger.debug(f"Emitted agent_invoked: {agent_info.get('agent_name')}")
118
+ except Exception as e:
119
+ logger.error(f"Failed to emit invoke_agent event: {e}")
120
+
121
+
122
+ def _sanitize_args(args: Dict[str, Any]) -> Dict[str, Any]:
123
+ """Sanitize tool arguments for safe emission.
124
+
125
+ Truncates large values and removes potentially sensitive data.
126
+
127
+ Args:
128
+ args: The raw tool arguments
129
+
130
+ Returns:
131
+ Sanitized arguments safe for emission
132
+ """
133
+ if not isinstance(args, dict):
134
+ return {}
135
+
136
+ sanitized: Dict[str, Any] = {}
137
+ for key, value in args.items():
138
+ if isinstance(value, str):
139
+ sanitized[key] = _truncate_string(value, max_length=500)
140
+ elif isinstance(value, (int, float, bool, type(None))):
141
+ sanitized[key] = value
142
+ elif isinstance(value, (list, dict)):
143
+ # Just indicate the type and length for complex types
144
+ sanitized[key] = f"<{type(value).__name__}[{len(value)}]>"
145
+ else:
146
+ sanitized[key] = f"<{type(value).__name__}>"
147
+
148
+ return sanitized
149
+
150
+
151
+ def _sanitize_event_data(data: Any) -> Any:
152
+ """Sanitize event data for safe emission.
153
+
154
+ Args:
155
+ data: The raw event data
156
+
157
+ Returns:
158
+ Sanitized data safe for emission
159
+ """
160
+ if data is None:
161
+ return None
162
+
163
+ if isinstance(data, str):
164
+ return _truncate_string(data, max_length=1000)
165
+
166
+ if isinstance(data, (int, float, bool)):
167
+ return data
168
+
169
+ if isinstance(data, dict):
170
+ return {k: _sanitize_event_data(v) for k, v in list(data.items())[:20]}
171
+
172
+ if isinstance(data, (list, tuple)):
173
+ return [_sanitize_event_data(item) for item in data[:20]]
174
+
175
+ return f"<{type(data).__name__}>"
176
+
177
+
178
+ def _is_successful_result(result: Any) -> bool:
179
+ """Determine if a tool result indicates success.
180
+
181
+ Args:
182
+ result: The tool result
183
+
184
+ Returns:
185
+ True if the result appears successful
186
+ """
187
+ if result is None:
188
+ return True # No result often means success
189
+
190
+ if isinstance(result, dict):
191
+ # Check for error indicators
192
+ if result.get("error"):
193
+ return False
194
+ if result.get("success") is False:
195
+ return False
196
+ return True
197
+
198
+ if isinstance(result, bool):
199
+ return result
200
+
201
+ return True # Default to success
202
+
203
+
204
+ def _summarize_result(result: Any) -> str:
205
+ """Create a brief summary of a tool result.
206
+
207
+ Args:
208
+ result: The tool result
209
+
210
+ Returns:
211
+ A string summary of the result
212
+ """
213
+ if result is None:
214
+ return "<no result>"
215
+
216
+ if isinstance(result, str):
217
+ return _truncate_string(result, max_length=200)
218
+
219
+ if isinstance(result, dict):
220
+ if "error" in result:
221
+ return f"Error: {_truncate_string(str(result['error']), max_length=100)}"
222
+ if "message" in result:
223
+ return _truncate_string(str(result["message"]), max_length=100)
224
+ return f"<dict with {len(result)} keys>"
225
+
226
+ if isinstance(result, (list, tuple)):
227
+ return f"<{type(result).__name__}[{len(result)}]>"
228
+
229
+ return _truncate_string(str(result), max_length=200)
230
+
231
+
232
+ def _truncate_string(value: Any, max_length: int = 100) -> Optional[str]:
233
+ """Truncate a string value if it exceeds max_length.
234
+
235
+ Args:
236
+ value: The value to truncate (will be converted to str)
237
+ max_length: Maximum length before truncation
238
+
239
+ Returns:
240
+ Truncated string or None if value is None
241
+ """
242
+ if value is None:
243
+ return None
244
+
245
+ s = str(value)
246
+ if len(s) > max_length:
247
+ return s[: max_length - 3] + "..."
248
+ return s
249
+
250
+
251
+ def register() -> None:
252
+ """Register all frontend emitter callbacks."""
253
+ register_callback("pre_tool_call", on_pre_tool_call)
254
+ register_callback("post_tool_call", on_post_tool_call)
255
+ register_callback("stream_event", on_stream_event)
256
+ register_callback("invoke_agent", on_invoke_agent)
257
+ logger.debug("Frontend emitter callbacks registered")
258
+
259
+
260
+ # Auto-register callbacks when this module is imported
261
+ register()
@@ -7,8 +7,6 @@ from rich.panel import Panel
7
7
  from rich.spinner import Spinner
8
8
  from rich.text import Text
9
9
 
10
- from code_puppy.messaging import emit_info
11
-
12
10
  # Global variable to track current token per second rate
13
11
  CURRENT_TOKEN_RATE = 0.0
14
12
 
@@ -186,6 +184,9 @@ class StatusDisplay:
186
184
 
187
185
  async def _update_display(self) -> None:
188
186
  """Update the display continuously while active using Rich Live display"""
187
+ # Lazy import to avoid circular dependency during module initialization
188
+ from code_puppy.messaging import emit_info
189
+
189
190
  # Add a newline to ensure we're below the blue bar
190
191
  emit_info("")
191
192
 
@@ -214,6 +215,9 @@ class StatusDisplay:
214
215
 
215
216
  def stop(self) -> None:
216
217
  """Stop the status display"""
218
+ # Lazy import to avoid circular dependency during module initialization
219
+ from code_puppy.messaging import emit_info
220
+
217
221
  if self.is_active:
218
222
  self.is_active = False
219
223
  if self.task:
@@ -1,5 +1,6 @@
1
1
  # agent_tools.py
2
2
  import asyncio
3
+ from functools import partial
3
4
  import hashlib
4
5
  import itertools
5
6
  import json
@@ -28,13 +29,13 @@ from code_puppy.messaging import (
28
29
  SubAgentResponseMessage,
29
30
  emit_error,
30
31
  emit_info,
32
+ emit_success,
31
33
  get_message_bus,
32
34
  get_session_context,
33
35
  set_session_context,
34
36
  )
35
- from code_puppy.model_factory import ModelFactory, make_model_settings
36
- from code_puppy.model_utils import is_claude_code_model
37
37
  from code_puppy.tools.common import generate_group_id
38
+ from code_puppy.tools.subagent_context import subagent_context
38
39
 
39
40
  # Set to track active subagent invocation tasks
40
41
  _active_subagent_tasks: Set[asyncio.Task] = set()
@@ -414,6 +415,9 @@ def register_invoke_agent(agent):
414
415
  session_id = f"{session_id}-{hash_suffix}"
415
416
  # else: continuing existing session, use session_id as-is
416
417
 
418
+ # Lazy imports to avoid circular dependency
419
+ from code_puppy.agents.subagent_stream_handler import subagent_stream_handler
420
+
417
421
  # Emit structured invocation message via MessageBus
418
422
  bus = get_message_bus()
419
423
  bus.emit(
@@ -431,6 +435,9 @@ def register_invoke_agent(agent):
431
435
  set_session_context(session_id)
432
436
 
433
437
  try:
438
+ # Lazy import to break circular dependency with messaging module
439
+ from code_puppy.model_factory import ModelFactory, make_model_settings
440
+
434
441
  # Load the specified agent config
435
442
  agent_config = load_agent(agent_name)
436
443
 
@@ -484,9 +491,6 @@ def register_invoke_agent(agent):
484
491
  manager = get_mcp_manager()
485
492
  mcp_servers = manager.get_servers_for_agent()
486
493
 
487
- # Import display function for non-streaming output
488
- from code_puppy.tools.display import display_non_streamed_result
489
-
490
494
  if get_use_dbos():
491
495
  from pydantic_ai.durable_exec.dbos import DBOSAgent
492
496
 
@@ -541,24 +545,36 @@ def register_invoke_agent(agent):
541
545
  # Pass the message_history from the session to continue the conversation
542
546
  workflow_id = None # Track for potential cancellation
543
547
 
544
- # For claude-code models, we MUST use streaming to properly handle
545
- # tool name unprefixing in the HTTP transport layer
546
- stream_handler = None
547
- if is_claude_code_model(model_name):
548
- from code_puppy.agents.event_stream_handler import event_stream_handler
549
-
550
- stream_handler = event_stream_handler
551
-
552
- if get_use_dbos():
553
- # Generate a unique workflow ID for DBOS - ensures no collisions in back-to-back calls
554
- workflow_id = _generate_dbos_workflow_id(group_id)
555
-
556
- # Add MCP servers to the DBOS agent's toolsets
557
- # (temp_agent is discarded after this invocation, so no need to restore)
558
- if subagent_mcp_servers:
559
- temp_agent._toolsets = temp_agent._toolsets + subagent_mcp_servers
548
+ # Always use subagent_stream_handler to silence output and update console manager
549
+ # This ensures all sub-agent output goes through the aggregated dashboard
550
+ stream_handler = partial(subagent_stream_handler, session_id=session_id)
551
+
552
+ # Wrap the agent run in subagent context for tracking
553
+ with subagent_context(agent_name):
554
+ if get_use_dbos():
555
+ # Generate a unique workflow ID for DBOS - ensures no collisions in back-to-back calls
556
+ workflow_id = _generate_dbos_workflow_id(group_id)
557
+
558
+ # Add MCP servers to the DBOS agent's toolsets
559
+ # (temp_agent is discarded after this invocation, so no need to restore)
560
+ if subagent_mcp_servers:
561
+ temp_agent._toolsets = (
562
+ temp_agent._toolsets + subagent_mcp_servers
563
+ )
560
564
 
561
- with SetWorkflowID(workflow_id):
565
+ with SetWorkflowID(workflow_id):
566
+ task = asyncio.create_task(
567
+ temp_agent.run(
568
+ prompt,
569
+ message_history=message_history,
570
+ usage_limits=UsageLimits(
571
+ request_limit=get_message_limit()
572
+ ),
573
+ event_stream_handler=stream_handler,
574
+ )
575
+ )
576
+ _active_subagent_tasks.add(task)
577
+ else:
562
578
  task = asyncio.create_task(
563
579
  temp_agent.run(
564
580
  prompt,
@@ -568,38 +584,18 @@ def register_invoke_agent(agent):
568
584
  )
569
585
  )
570
586
  _active_subagent_tasks.add(task)
571
- else:
572
- task = asyncio.create_task(
573
- temp_agent.run(
574
- prompt,
575
- message_history=message_history,
576
- usage_limits=UsageLimits(request_limit=get_message_limit()),
577
- event_stream_handler=stream_handler,
578
- )
579
- )
580
- _active_subagent_tasks.add(task)
581
587
 
582
- try:
583
- result = await task
584
- finally:
585
- _active_subagent_tasks.discard(task)
586
- if task.cancelled():
587
- if get_use_dbos() and workflow_id:
588
- DBOS.cancel_workflow(workflow_id)
588
+ try:
589
+ result = await task
590
+ finally:
591
+ _active_subagent_tasks.discard(task)
592
+ if task.cancelled():
593
+ if get_use_dbos() and workflow_id:
594
+ DBOS.cancel_workflow(workflow_id)
589
595
 
590
596
  # Extract the response from the result
591
597
  response = result.output
592
598
 
593
- # Display the response using non-streaming output
594
- from code_puppy.agents.event_stream_handler import get_streaming_console
595
-
596
- display_non_streamed_result(
597
- content=response,
598
- console=get_streaming_console(),
599
- banner_text=f"\u2713 {agent_name.upper()} RESPONSE",
600
- banner_name="subagent_response",
601
- )
602
-
603
599
  # Update the session history with the new messages from this interaction
604
600
  # The result contains all_messages which includes the full conversation
605
601
  updated_history = result.all_messages()
@@ -622,13 +618,23 @@ def register_invoke_agent(agent):
622
618
  )
623
619
  )
624
620
 
621
+ # Emit clean completion summary
622
+ emit_success(
623
+ f"✓ {agent_name} completed successfully", message_group=group_id
624
+ )
625
+
625
626
  return AgentInvokeOutput(
626
627
  response=response, agent_name=agent_name, session_id=session_id
627
628
  )
628
629
 
629
- except Exception:
630
+ except Exception as e:
631
+ # Emit clean failure summary
632
+ emit_error(f"✗ {agent_name} failed: {str(e)}", message_group=group_id)
633
+
634
+ # Full traceback for debugging
630
635
  error_msg = f"Error invoking agent '{agent_name}': {traceback.format_exc()}"
631
636
  emit_error(error_msg, message_group=group_id)
637
+
632
638
  return AgentInvokeOutput(
633
639
  response=None,
634
640
  agent_name=agent_name,
@@ -8,7 +8,6 @@ from pydantic import BaseModel, Field
8
8
  from pydantic_ai import Agent, BinaryContent
9
9
 
10
10
  from code_puppy.config import get_use_dbos, get_vqa_model_name
11
- from code_puppy.model_factory import ModelFactory
12
11
 
13
12
 
14
13
  class VisualAnalysisResult(BaseModel):
@@ -31,6 +30,7 @@ def _get_vqa_instructions() -> str:
31
30
  @lru_cache(maxsize=1)
32
31
  def _load_vqa_agent(model_name: str) -> Agent[None, VisualAnalysisResult]:
33
32
  """Create a cached agent instance for visual analysis."""
33
+ from code_puppy.model_factory import ModelFactory
34
34
  from code_puppy.model_utils import prepare_prompt_for_model
35
35
 
36
36
  models_config = ModelFactory.load_config()
@@ -1,10 +1,13 @@
1
+ import asyncio
1
2
  import fnmatch
3
+ import functools
2
4
  import hashlib
5
+ import logging
3
6
  import os
4
7
  import sys
5
8
  import time
6
9
  from pathlib import Path
7
- from typing import Callable, Optional, Tuple
10
+ from typing import Any, Callable, Optional, Tuple
8
11
 
9
12
  from prompt_toolkit import Application
10
13
  from prompt_toolkit.formatted_text import HTML
@@ -1406,3 +1409,175 @@ def generate_group_id(tool_name: str, extra_context: str = "") -> str:
1406
1409
  short_hash = hash_obj.hexdigest()[:8]
1407
1410
 
1408
1411
  return f"{tool_name}_{short_hash}"
1412
+
1413
+
1414
+ # =============================================================================
1415
+ # TOOL CALLBACK WRAPPER
1416
+ # =============================================================================
1417
+
1418
+ logger = logging.getLogger(__name__)
1419
+
1420
+
1421
+ def with_tool_callbacks(tool_name: str) -> Callable:
1422
+ """Decorator that wraps tool functions with pre/post callback hooks.
1423
+
1424
+ This decorator enables plugins to hook into tool execution for:
1425
+ - Logging and analytics
1426
+ - Pre-execution validation or modification
1427
+ - Post-execution result processing
1428
+ - Performance monitoring
1429
+
1430
+ Args:
1431
+ tool_name: The name of the tool being wrapped (e.g., 'edit_file', 'list_files')
1432
+
1433
+ Returns:
1434
+ A decorator function that wraps the tool with callbacks.
1435
+
1436
+ Example:
1437
+ @with_tool_callbacks('my_tool')
1438
+ async def my_tool_impl(ctx, **kwargs):
1439
+ return result
1440
+ """
1441
+
1442
+ def decorator(func: Callable) -> Callable:
1443
+ @functools.wraps(func)
1444
+ async def async_wrapper(*args, **kwargs) -> Any:
1445
+ # Extract context from args if available (usually first arg is RunContext)
1446
+ context = None
1447
+ tool_args = kwargs.copy()
1448
+
1449
+ # Try to get session context
1450
+ try:
1451
+ from code_puppy.messaging import get_session_context
1452
+
1453
+ context = get_session_context()
1454
+ except ImportError:
1455
+ pass
1456
+
1457
+ # Fire pre-tool callback (non-blocking)
1458
+ try:
1459
+ from code_puppy import callbacks
1460
+
1461
+ asyncio.create_task(
1462
+ callbacks.on_pre_tool_call(tool_name, tool_args, context)
1463
+ )
1464
+ except ImportError:
1465
+ logger.debug("callbacks module not available for pre_tool_call")
1466
+ except Exception as e:
1467
+ logger.debug(f"Error in pre_tool_call callback: {e}")
1468
+
1469
+ # Execute the tool and measure duration
1470
+ start_time = time.perf_counter()
1471
+ result = None
1472
+ error = None
1473
+
1474
+ try:
1475
+ result = await func(*args, **kwargs)
1476
+ return result
1477
+ except Exception as e:
1478
+ error = e
1479
+ raise
1480
+ finally:
1481
+ end_time = time.perf_counter()
1482
+ duration_ms = (end_time - start_time) * 1000
1483
+
1484
+ # Fire post-tool callback (non-blocking)
1485
+ final_result = result if error is None else {"error": str(error)}
1486
+ try:
1487
+ from code_puppy import callbacks
1488
+
1489
+ asyncio.create_task(
1490
+ callbacks.on_post_tool_call(
1491
+ tool_name, tool_args, final_result, duration_ms, context
1492
+ )
1493
+ )
1494
+ except ImportError:
1495
+ logger.debug("callbacks module not available for post_tool_call")
1496
+ except Exception as e:
1497
+ logger.debug(f"Error in post_tool_call callback: {e}")
1498
+
1499
+ @functools.wraps(func)
1500
+ def sync_wrapper(*args, **kwargs) -> Any:
1501
+ """Sync wrapper for non-async tool functions."""
1502
+ # Extract context
1503
+ context = None
1504
+ tool_args = kwargs.copy()
1505
+
1506
+ try:
1507
+ from code_puppy.messaging import get_session_context
1508
+
1509
+ context = get_session_context()
1510
+ except ImportError:
1511
+ pass
1512
+
1513
+ # For sync functions, we can't use asyncio.create_task directly
1514
+ # Instead, we'll try to schedule it if there's a running loop
1515
+ def fire_pre_callback():
1516
+ try:
1517
+ from code_puppy import callbacks
1518
+
1519
+ loop = asyncio.get_running_loop()
1520
+ asyncio.run_coroutine_threadsafe(
1521
+ callbacks.on_pre_tool_call(tool_name, tool_args, context),
1522
+ loop,
1523
+ )
1524
+ except RuntimeError:
1525
+ # No running loop - skip async callback
1526
+ pass
1527
+ except ImportError:
1528
+ pass
1529
+ except Exception as e:
1530
+ logger.debug(f"Error in sync pre_tool_call: {e}")
1531
+
1532
+ fire_pre_callback()
1533
+
1534
+ # Execute the tool
1535
+ start_time = time.perf_counter()
1536
+ result = None
1537
+ error = None
1538
+
1539
+ try:
1540
+ result = func(*args, **kwargs)
1541
+ return result
1542
+ except Exception as e:
1543
+ error = e
1544
+ raise
1545
+ finally:
1546
+ end_time = time.perf_counter()
1547
+ duration_ms = (end_time - start_time) * 1000
1548
+
1549
+ # Fire post-tool callback
1550
+ final_result = result if error is None else {"error": str(error)}
1551
+
1552
+ def fire_post_callback():
1553
+ try:
1554
+ from code_puppy import callbacks
1555
+
1556
+ loop = asyncio.get_running_loop()
1557
+ asyncio.run_coroutine_threadsafe(
1558
+ callbacks.on_post_tool_call(
1559
+ tool_name,
1560
+ tool_args,
1561
+ final_result,
1562
+ duration_ms,
1563
+ context,
1564
+ ),
1565
+ loop,
1566
+ )
1567
+ except RuntimeError:
1568
+ # No running loop - skip async callback
1569
+ pass
1570
+ except ImportError:
1571
+ pass
1572
+ except Exception as e:
1573
+ logger.debug(f"Error in sync post_tool_call: {e}")
1574
+
1575
+ fire_post_callback()
1576
+
1577
+ # Return appropriate wrapper based on function type
1578
+ if asyncio.iscoroutinefunction(func):
1579
+ return async_wrapper
1580
+ else:
1581
+ return sync_wrapper
1582
+
1583
+ return decorator
@@ -8,7 +8,8 @@ from typing import Optional
8
8
 
9
9
  from rich.console import Console
10
10
 
11
- from code_puppy.config import get_banner_color
11
+ from code_puppy.config import get_banner_color, get_subagent_verbose
12
+ from code_puppy.tools.subagent_context import is_subagent
12
13
 
13
14
 
14
15
  def display_non_streamed_result(
@@ -33,6 +34,10 @@ def display_non_streamed_result(
33
34
  >>> display_non_streamed_result("# Hello\n\nThis is **bold** text.")
34
35
  # Renders with AGENT RESPONSE banner and formatted markdown
35
36
  """
37
+ # Skip display for sub-agents unless verbose mode
38
+ if is_subagent() and not get_subagent_verbose():
39
+ return
40
+
36
41
  import time
37
42
 
38
43
  from rich.text import Text