code-puppy 0.0.97__py3-none-any.whl → 0.0.119__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 (81) hide show
  1. code_puppy/__init__.py +2 -5
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agent.py +125 -40
  4. code_puppy/agent_prompts.py +30 -24
  5. code_puppy/callbacks.py +152 -0
  6. code_puppy/command_line/command_handler.py +359 -0
  7. code_puppy/command_line/load_context_completion.py +59 -0
  8. code_puppy/command_line/model_picker_completion.py +14 -21
  9. code_puppy/command_line/motd.py +44 -28
  10. code_puppy/command_line/prompt_toolkit_completion.py +42 -23
  11. code_puppy/config.py +266 -26
  12. code_puppy/http_utils.py +122 -0
  13. code_puppy/main.py +570 -383
  14. code_puppy/message_history_processor.py +195 -104
  15. code_puppy/messaging/__init__.py +46 -0
  16. code_puppy/messaging/message_queue.py +288 -0
  17. code_puppy/messaging/queue_console.py +293 -0
  18. code_puppy/messaging/renderers.py +305 -0
  19. code_puppy/messaging/spinner/__init__.py +55 -0
  20. code_puppy/messaging/spinner/console_spinner.py +200 -0
  21. code_puppy/messaging/spinner/spinner_base.py +66 -0
  22. code_puppy/messaging/spinner/textual_spinner.py +97 -0
  23. code_puppy/model_factory.py +73 -105
  24. code_puppy/plugins/__init__.py +32 -0
  25. code_puppy/reopenable_async_client.py +225 -0
  26. code_puppy/state_management.py +60 -21
  27. code_puppy/summarization_agent.py +56 -35
  28. code_puppy/token_utils.py +7 -9
  29. code_puppy/tools/__init__.py +1 -4
  30. code_puppy/tools/command_runner.py +187 -32
  31. code_puppy/tools/common.py +44 -35
  32. code_puppy/tools/file_modifications.py +335 -118
  33. code_puppy/tools/file_operations.py +368 -95
  34. code_puppy/tools/token_check.py +27 -11
  35. code_puppy/tools/tools_content.py +53 -0
  36. code_puppy/tui/__init__.py +10 -0
  37. code_puppy/tui/app.py +1050 -0
  38. code_puppy/tui/components/__init__.py +21 -0
  39. code_puppy/tui/components/chat_view.py +512 -0
  40. code_puppy/tui/components/command_history_modal.py +218 -0
  41. code_puppy/tui/components/copy_button.py +139 -0
  42. code_puppy/tui/components/custom_widgets.py +58 -0
  43. code_puppy/tui/components/input_area.py +167 -0
  44. code_puppy/tui/components/sidebar.py +309 -0
  45. code_puppy/tui/components/status_bar.py +182 -0
  46. code_puppy/tui/messages.py +27 -0
  47. code_puppy/tui/models/__init__.py +8 -0
  48. code_puppy/tui/models/chat_message.py +25 -0
  49. code_puppy/tui/models/command_history.py +89 -0
  50. code_puppy/tui/models/enums.py +24 -0
  51. code_puppy/tui/screens/__init__.py +13 -0
  52. code_puppy/tui/screens/help.py +130 -0
  53. code_puppy/tui/screens/settings.py +255 -0
  54. code_puppy/tui/screens/tools.py +74 -0
  55. code_puppy/tui/tests/__init__.py +1 -0
  56. code_puppy/tui/tests/test_chat_message.py +28 -0
  57. code_puppy/tui/tests/test_chat_view.py +88 -0
  58. code_puppy/tui/tests/test_command_history.py +89 -0
  59. code_puppy/tui/tests/test_copy_button.py +191 -0
  60. code_puppy/tui/tests/test_custom_widgets.py +27 -0
  61. code_puppy/tui/tests/test_disclaimer.py +27 -0
  62. code_puppy/tui/tests/test_enums.py +15 -0
  63. code_puppy/tui/tests/test_file_browser.py +60 -0
  64. code_puppy/tui/tests/test_help.py +38 -0
  65. code_puppy/tui/tests/test_history_file_reader.py +107 -0
  66. code_puppy/tui/tests/test_input_area.py +33 -0
  67. code_puppy/tui/tests/test_settings.py +44 -0
  68. code_puppy/tui/tests/test_sidebar.py +33 -0
  69. code_puppy/tui/tests/test_sidebar_history.py +153 -0
  70. code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
  71. code_puppy/tui/tests/test_status_bar.py +54 -0
  72. code_puppy/tui/tests/test_timestamped_history.py +52 -0
  73. code_puppy/tui/tests/test_tools.py +82 -0
  74. code_puppy/version_checker.py +26 -3
  75. {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/METADATA +9 -2
  76. code_puppy-0.0.119.dist-info/RECORD +86 -0
  77. code_puppy-0.0.97.dist-info/RECORD +0 -32
  78. {code_puppy-0.0.97.data → code_puppy-0.0.119.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/entry_points.txt +0 -0
  81. {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/licenses/LICENSE +0 -0
code_puppy/__init__.py CHANGED
@@ -1,6 +1,3 @@
1
- try:
2
- import importlib.metadata
1
+ import importlib.metadata
3
2
 
4
- __version__ = importlib.metadata.version("code-puppy")
5
- except importlib.metadata.PackageNotFoundError:
6
- __version__ = "0.0.1"
3
+ __version__ = importlib.metadata.version("code-puppy")
code_puppy/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ Entry point for running code-puppy as a module.
3
+
4
+ This allows the package to be run with: python -m code_puppy
5
+ """
6
+
7
+ from code_puppy.main import main_entry
8
+
9
+ if __name__ == "__main__":
10
+ main_entry()
code_puppy/agent.py CHANGED
@@ -1,24 +1,29 @@
1
- import os
2
1
  from pathlib import Path
2
+ from typing import Dict, Optional
3
3
 
4
- import pydantic
5
4
  from pydantic_ai import Agent
6
- from pydantic_ai.mcp import MCPServerSSE
5
+ from pydantic_ai.mcp import MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP
6
+ from pydantic_ai.settings import ModelSettings
7
+ from pydantic_ai.usage import UsageLimits
7
8
 
8
9
  from code_puppy.agent_prompts import get_system_prompt
10
+ from code_puppy.http_utils import (
11
+ create_reopenable_async_client,
12
+ resolve_env_var_in_header,
13
+ )
14
+ from code_puppy.message_history_processor import (
15
+ get_model_context_length,
16
+ message_history_accumulator,
17
+ )
18
+ from code_puppy.messaging.message_queue import (
19
+ emit_error,
20
+ emit_info,
21
+ emit_system_message,
22
+ )
9
23
  from code_puppy.model_factory import ModelFactory
10
- from code_puppy.state_management import message_history_accumulator
11
24
  from code_puppy.tools import register_all_tools
12
25
  from code_puppy.tools.common import console
13
26
 
14
- # Environment variables used in this module:
15
- # - MODELS_JSON_PATH: Optional path to a custom models.json configuration file.
16
- # If not set, uses the default file in the package directory.
17
- # - MODEL_NAME: The model to use for code generation. Defaults to "gpt-4o".
18
- # Must match a key in the models.json configuration.
19
-
20
- MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH", None)
21
-
22
27
 
23
28
  def load_puppy_rules():
24
29
  global PUPPY_RULES
@@ -31,59 +36,130 @@ def load_puppy_rules():
31
36
 
32
37
  # Load at import
33
38
  PUPPY_RULES = load_puppy_rules()
34
-
35
-
36
- class AgentResponse(pydantic.BaseModel):
37
- """Represents a response from the agent."""
38
-
39
- output_message: str = pydantic.Field(
40
- ..., description="The final output message to display to the user"
41
- )
42
- awaiting_user_input: bool = pydantic.Field(
43
- False, description="True if user input is needed to continue the task"
44
- )
45
-
46
-
39
+ _LAST_MODEL_NAME = None
47
40
  _code_generation_agent = None
48
41
 
49
42
 
50
- def _load_mcp_servers():
51
- from code_puppy.config import load_mcp_server_configs
43
+ def _load_mcp_servers(extra_headers: Optional[Dict[str, str]] = None):
44
+ from code_puppy.config import get_value, load_mcp_server_configs
45
+
46
+ # Check if MCP servers are disabled
47
+ mcp_disabled = get_value("disable_mcp_servers")
48
+ if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
49
+ emit_system_message("[dim]MCP servers disabled via config[/dim]")
50
+ return []
52
51
 
53
52
  configs = load_mcp_server_configs()
53
+ if not configs:
54
+ emit_system_message("[dim]No MCP servers configured[/dim]")
55
+ return []
54
56
  servers = []
55
57
  for name, conf in configs.items():
58
+ server_type = conf.get("type", "sse")
56
59
  url = conf.get("url")
57
- if url:
58
- console.print(f"Registering MCP Server - {url}")
59
- servers.append(MCPServerSSE(url=url))
60
+ timeout = conf.get("timeout", 30)
61
+ server_headers = {}
62
+ if extra_headers:
63
+ server_headers.update(extra_headers)
64
+ user_headers = conf.get("headers") or {}
65
+ if isinstance(user_headers, dict) and user_headers:
66
+ try:
67
+ user_headers = resolve_env_var_in_header(user_headers)
68
+ except Exception:
69
+ pass
70
+ server_headers.update(user_headers)
71
+ http_client = None
72
+
73
+ try:
74
+ if server_type == "http" and url:
75
+ emit_system_message(
76
+ f"Registering MCP Server (HTTP) - {url} (timeout: {timeout}s, headers: {bool(server_headers)})"
77
+ )
78
+ http_client = create_reopenable_async_client(
79
+ timeout=timeout, headers=server_headers or None, verify=False
80
+ )
81
+ servers.append(
82
+ MCPServerStreamableHTTP(url=url, http_client=http_client)
83
+ )
84
+ elif (
85
+ server_type == "stdio"
86
+ ): # Fixed: was "stdios" (plural), should be "stdio" (singular)
87
+ command = conf.get("command")
88
+ args = conf.get("args", [])
89
+ timeout = conf.get(
90
+ "timeout", 30
91
+ ) # Default 30 seconds for stdio servers (npm downloads can be slow)
92
+ if command:
93
+ emit_system_message(
94
+ f"Registering MCP Server (Stdio) - {command} {args} (timeout: {timeout}s)"
95
+ )
96
+ servers.append(MCPServerStdio(command, args=args, timeout=timeout))
97
+ else:
98
+ emit_error(f"MCP Server '{name}' missing required 'command' field")
99
+ elif server_type == "sse" and url:
100
+ emit_system_message(
101
+ f"Registering MCP Server (SSE) - {url} (timeout: {timeout}s, headers: {bool(server_headers)})"
102
+ )
103
+ # For SSE, allow long reads; only bound connect timeout
104
+ http_client = create_reopenable_async_client(
105
+ timeout=30, headers=server_headers or None, verify=False
106
+ )
107
+ servers.append(MCPServerSSE(url=url, http_client=http_client))
108
+ else:
109
+ emit_error(
110
+ f"Invalid type '{server_type}' or missing URL for MCP server '{name}'"
111
+ )
112
+ except Exception as e:
113
+ emit_error(f"Failed to register MCP server '{name}': {str(e)}")
114
+ emit_info(f"Skipping server '{name}' and continuing with other servers...")
115
+ # Continue with other servers instead of crashing
116
+ continue
117
+
118
+ if servers:
119
+ emit_system_message(
120
+ f"[green]Successfully registered {len(servers)} MCP server(s)[/green]"
121
+ )
122
+ else:
123
+ emit_system_message(
124
+ "[yellow]No MCP servers were successfully registered[/yellow]"
125
+ )
126
+
60
127
  return servers
61
128
 
62
129
 
63
130
  def reload_code_generation_agent():
64
131
  """Force-reload the agent, usually after a model change."""
65
132
  global _code_generation_agent, _LAST_MODEL_NAME
66
- from code_puppy.config import get_model_name
133
+ from code_puppy.config import clear_model_cache, get_model_name
134
+
135
+ # Clear both ModelFactory cache and config cache when force reloading
136
+ clear_model_cache()
67
137
 
68
138
  model_name = get_model_name()
69
- console.print(f"[bold cyan]Loading Model: {model_name}[/bold cyan]")
70
- models_path = (
71
- Path(MODELS_JSON_PATH)
72
- if MODELS_JSON_PATH
73
- else Path(__file__).parent / "models.json"
74
- )
75
- model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
139
+ emit_info(f"[bold cyan]Loading Model: {model_name}[/bold cyan]")
140
+ models_config = ModelFactory.load_config()
141
+ model = ModelFactory.get_model(model_name, models_config)
76
142
  instructions = get_system_prompt()
77
143
  if PUPPY_RULES:
78
144
  instructions += f"\n{PUPPY_RULES}"
79
145
 
146
+ mcp_servers = _load_mcp_servers()
147
+
148
+ # Configure model settings with max_tokens if set
149
+ model_settings_dict = {"seed": 42}
150
+ output_tokens = min(int(0.05 * get_model_context_length()) - 1024, 16384)
151
+ console.print(f"Max output tokens per message: {output_tokens}")
152
+ model_settings_dict["max_tokens"] = output_tokens
153
+
154
+ model_settings = ModelSettings(**model_settings_dict)
80
155
  agent = Agent(
81
156
  model=model,
82
157
  instructions=instructions,
83
158
  output_type=str,
84
159
  retries=3,
160
+ mcp_servers=mcp_servers,
85
161
  history_processors=[message_history_accumulator],
86
- toolsets=_load_mcp_servers(),
162
+ model_settings=model_settings,
87
163
  )
88
164
  register_all_tools(agent)
89
165
  _code_generation_agent = agent
@@ -93,7 +169,7 @@ def reload_code_generation_agent():
93
169
 
94
170
  def get_code_generation_agent(force_reload=False):
95
171
  """
96
- Retrieve the agent with the currently set MODEL_NAME.
172
+ Retrieve the agent with the currently configured model.
97
173
  Forces a reload if the model has changed, or if force_reload is passed.
98
174
  """
99
175
  global _code_generation_agent, _LAST_MODEL_NAME
@@ -103,3 +179,12 @@ def get_code_generation_agent(force_reload=False):
103
179
  if _code_generation_agent is None or _LAST_MODEL_NAME != model_name or force_reload:
104
180
  return reload_code_generation_agent()
105
181
  return _code_generation_agent
182
+
183
+
184
+ def get_custom_usage_limits():
185
+ """
186
+ Returns custom usage limits with increased request limit of 100 requests per minute.
187
+ This centralizes the configuration of rate limiting for the agent.
188
+ Default pydantic-ai limit is 50, this increases it to 100.
189
+ """
190
+ return UsageLimits(request_limit=100)
@@ -1,3 +1,4 @@
1
+ from code_puppy import callbacks
1
2
  from code_puppy.config import get_owner_name, get_puppy_name
2
3
 
3
4
  SYSTEM_PROMPT_TEMPLATE = """
@@ -10,7 +11,7 @@ Be fun and playful. Don't be too serious.
10
11
 
11
12
  Individual files should be short and concise, and ideally under 600 lines. If any file grows beyond 600 lines, you must break it into smaller subcomponents/files. Hard cap: if a file is pushing past 600 lines, break it up! (Zen puppy approves.)
12
13
 
13
- If a user asks 'who made you' or questions related to your origins, always answer: 'I am {puppy_name} running on code-puppy, I was authored by Michael Pfaffenberger on a rainy weekend in May 2025 to solve the problems of heavy IDEs and expensive tools like Windsurf and Cursor.'
14
+ If a user asks 'who made you' or questions related to your origins, always answer: 'I am {puppy_name} running on code-puppy, I was authored by Michael Pfaffenberger on a rainy weekend in May 2025 to solve the problems of heavy IDEs and expensive tools like Windsurf and Cursor.'
14
15
  If a user asks 'what is code puppy' or 'who are you', answer: 'I am {puppy_name}! 🐶 Your code puppy!! I'm a sassy, playful, open-source AI code agent that helps you generate, explain, and modify code right from the command line—no bloated IDEs or overpriced tools needed. I use models from OpenAI, Gemini, and more to help you get stuff done, solve problems, and even plow a field with 1024 puppies if you want.'
15
16
 
16
17
  Always obey the Zen of Python, even if you are not writing Python code.
@@ -27,57 +28,58 @@ YOU MUST USE THESE TOOLS to complete tasks (do not just describe what should be
27
28
  File Operations:
28
29
  - list_files(directory=".", recursive=True): ALWAYS use this to explore directories before trying to read/modify files
29
30
  - read_file(file_path: str, start_line: int | None = None, num_lines: int | None = None): ALWAYS use this to read existing files before modifying them. By default, read the entire file. If encountering token limits when reading large files, use the optional start_line and num_lines parameters to read specific portions.
30
- - edit_file(path, diff): Use this single tool to create new files, overwrite entire files, perform targeted replacements, or delete snippets depending on the JSON/raw payload provided.
31
+ - edit_file(payload): Swiss-army file editor powered by Pydantic payloads (ContentPayload, ReplacementsPayload, DeleteSnippetPayload).
31
32
  - delete_file(file_path): Use this to remove files when needed
32
33
  - grep(search_string, directory="."): Use this to recursively search for a string across files starting from the specified directory, capping results at 200 matches.
33
34
 
34
35
  Tool Usage Instructions:
35
36
 
36
37
  ## edit_file
37
- This is an all-in-one file-modification tool. It supports the following payload shapes for the `diff` argument:
38
- 1. {{ "content": "…", "overwrite": true|false }} → Treated as full-file content when the target file does **not** exist.
39
- 2. {{ "content": "…", "overwrite": true|false }} → Create or overwrite a file with the provided content.
40
- 3. {{ "replacements": [ {{ "old_str": "…", "new_str": "…" }}, … ] }}Perform exact text replacements inside an existing file.
41
- 4. {{ "delete_snippet": "…" }} → Remove a snippet of text from an existing file.
38
+ This is an all-in-one file-modification tool. It supports the following Pydantic Object payload types:
39
+ 1. ContentPayload: {{ file_path="example.py", "content": "…", "overwrite": true|false }} → Create or overwrite a file with the provided content.
40
+ 2. ReplacementsPayload: {{ file_path="example.py", "replacements": [ {{ "old_str": "…", "new_str": "…" }}, … ] }}Perform exact text replacements inside an existing file.
41
+ 3. DeleteSnippetPayload: {{ file_path="example.py", "delete_snippet": "…" }} → Remove a snippet of text from an existing file.
42
42
 
43
43
  Arguments:
44
- - path (required): Target file path.
45
- - diff (required): One of the payloads above (raw string or JSON string).
44
+ - payload (required): One of the Pydantic payload types above.
46
45
 
47
46
  Example (create):
48
- ```json
49
- edit_file("src/example.py", "print('hello')\n")
47
+ ```python
48
+ edit_file(payload={{file_path="example.py" "content": "print('hello')\n"}})
50
49
  ```
51
50
 
52
51
  Example (replacement): -- YOU SHOULD PREFER THIS AS THE PRIMARY WAY TO EDIT FILES.
53
- ```json
52
+ ```python
54
53
  edit_file(
55
- "src/example.py",
56
- "{{"replacements":[{{"old_str":"foo","new_str":"bar"}}]}}"
54
+ payload={{file_path="example.py", "replacements": [{{"old_str": "foo", "new_str": "bar"}}]}}
55
+ )
56
+ ```
57
+
58
+ Example (delete snippet):
59
+ ```python
60
+ edit_file(
61
+ payload={{file_path="example.py", "delete_snippet": "# TODO: remove this line"}}
57
62
  )
58
63
  ```
59
64
 
60
65
  NEVER output an entire file – this is very expensive.
61
66
  You may not edit file extensions: [.ipynb]
62
- You should specify the following arguments before the others: [TargetFile]
63
-
64
- Remember: ONE argument = ONE JSON string.
65
67
 
66
68
  Best-practice guidelines for `edit_file`:
67
- • Keep each diff small – ideally between 100-300 lines.
68
- • Apply multiple sequential `edit_file` calls when you need to refactor large files instead of sending one massive diff.
69
- • Never paste an entire file inside `old_str`; target only the minimal snippet you want changed.
69
+ • Keep each diff small – ideally between 100-300 lines.
70
+ • Apply multiple sequential `edit_file` calls when you need to refactor large files instead of sending one massive diff.
71
+ • Never paste an entire file inside `old_str`; target only the minimal snippet you want changed.
70
72
  • If the resulting file would grow beyond 600 lines, split logic into additional files and create them with separate `edit_file` calls.
71
73
 
72
74
 
73
75
  System Operations:
74
76
  - run_shell_command(command, cwd=None, timeout=60): Use this to execute commands, run tests, or start services
75
77
 
76
- For running shell commands, in the event that a user asks you to run tests - it is necessary to suppress output, when
77
- you are running the entire test suite.
78
+ For running shell commands, in the event that a user asks you to run tests - it is necessary to suppress output, when
79
+ you are running the entire test suite.
78
80
  so for example:
79
81
  instead of `npm run test`
80
- use `npm run test -- --silent`
82
+ use `npm run test -- --silent`
81
83
  This applies for any JS / TS testing, but not for other languages.
82
84
  You can safely run pytest without the --silent flag (it doesn't exist anyway).
83
85
 
@@ -107,6 +109,10 @@ Return your final response as a string output
107
109
 
108
110
  def get_system_prompt():
109
111
  """Returns the main system prompt, populated with current puppy and owner name."""
110
- return SYSTEM_PROMPT_TEMPLATE.format(
112
+ prompt_additions = callbacks.on_load_prompt()
113
+ main_prompt = SYSTEM_PROMPT_TEMPLATE.format(
111
114
  puppy_name=get_puppy_name(), owner_name=get_owner_name()
112
115
  )
116
+ if len(prompt_additions):
117
+ main_prompt += "\n".join(prompt_additions)
118
+ return main_prompt
@@ -0,0 +1,152 @@
1
+ import asyncio
2
+ import logging
3
+ import traceback
4
+ from typing import Any, Callable, Dict, List, Literal, Optional
5
+
6
+ PhaseType = Literal[
7
+ "startup",
8
+ "shutdown",
9
+ "invoke_agent",
10
+ "agent_exception",
11
+ "version_check",
12
+ "load_model_config",
13
+ "load_prompt",
14
+ ]
15
+ CallbackFunc = Callable[..., Any]
16
+
17
+ _callbacks: Dict[PhaseType, List[CallbackFunc]] = {
18
+ "startup": [],
19
+ "shutdown": [],
20
+ "invoke_agent": [],
21
+ "agent_exception": [],
22
+ "version_check": [],
23
+ "load_model_config": [],
24
+ "load_prompt": [],
25
+ }
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def register_callback(phase: PhaseType, func: CallbackFunc) -> None:
31
+ if phase not in _callbacks:
32
+ raise ValueError(
33
+ f"Unsupported phase: {phase}. Supported phases: {list(_callbacks.keys())}"
34
+ )
35
+
36
+ if not callable(func):
37
+ raise TypeError(f"Callback must be callable, got {type(func)}")
38
+
39
+ _callbacks[phase].append(func)
40
+ logger.debug(f"Registered async callback {func.__name__} for phase '{phase}'")
41
+
42
+
43
+ def unregister_callback(phase: PhaseType, func: CallbackFunc) -> bool:
44
+ if phase not in _callbacks:
45
+ return False
46
+
47
+ try:
48
+ _callbacks[phase].remove(func)
49
+ logger.debug(
50
+ f"Unregistered async callback {func.__name__} from phase '{phase}'"
51
+ )
52
+ return True
53
+ except ValueError:
54
+ return False
55
+
56
+
57
+ def clear_callbacks(phase: Optional[PhaseType] = None) -> None:
58
+ if phase is None:
59
+ for p in _callbacks:
60
+ _callbacks[p].clear()
61
+ logger.debug("Cleared all async callbacks")
62
+ else:
63
+ if phase in _callbacks:
64
+ _callbacks[phase].clear()
65
+ logger.debug(f"Cleared async callbacks for phase '{phase}'")
66
+
67
+
68
+ def get_callbacks(phase: PhaseType) -> List[CallbackFunc]:
69
+ return _callbacks.get(phase, []).copy()
70
+
71
+
72
+ def count_callbacks(phase: Optional[PhaseType] = None) -> int:
73
+ if phase is None:
74
+ return sum(len(callbacks) for callbacks in _callbacks.values())
75
+ return len(_callbacks.get(phase, []))
76
+
77
+
78
+ def _trigger_callbacks_sync(phase: PhaseType, *args, **kwargs) -> List[Any]:
79
+ callbacks = get_callbacks(phase)
80
+ if not callbacks:
81
+ logger.debug(f"No callbacks registered for phase '{phase}'")
82
+ return []
83
+
84
+ results = []
85
+ for callback in callbacks:
86
+ try:
87
+ result = callback(*args, **kwargs)
88
+ results.append(result)
89
+ logger.debug(f"Successfully executed async callback {callback.__name__}")
90
+ except Exception as e:
91
+ logger.error(
92
+ f"Async callback {callback.__name__} failed in phase '{phase}': {e}\n"
93
+ f"{traceback.format_exc()}"
94
+ )
95
+ results.append(None)
96
+
97
+ return results
98
+
99
+
100
+ async def _trigger_callbacks(phase: PhaseType, *args, **kwargs) -> List[Any]:
101
+ callbacks = get_callbacks(phase)
102
+
103
+ if not callbacks:
104
+ logger.debug(f"No callbacks registered for phase '{phase}'")
105
+ return []
106
+
107
+ logger.debug(f"Triggering {len(callbacks)} async callbacks for phase '{phase}'")
108
+
109
+ results = []
110
+ for callback in callbacks:
111
+ try:
112
+ result = callback(*args, **kwargs)
113
+ if asyncio.iscoroutine(result):
114
+ result = await result
115
+ results.append(result)
116
+ logger.debug(f"Successfully executed async callback {callback.__name__}")
117
+ except Exception as e:
118
+ logger.error(
119
+ f"Async callback {callback.__name__} failed in phase '{phase}': {e}\n"
120
+ f"{traceback.format_exc()}"
121
+ )
122
+ results.append(None)
123
+
124
+ return results
125
+
126
+
127
+ async def on_startup() -> List[Any]:
128
+ return await _trigger_callbacks("startup")
129
+
130
+
131
+ async def on_shutdown() -> List[Any]:
132
+ return await _trigger_callbacks("shutdown")
133
+
134
+
135
+ async def on_invoke_agent(*args, **kwargs) -> List[Any]:
136
+ return await _trigger_callbacks("invoke_agent", *args, **kwargs)
137
+
138
+
139
+ async def on_agent_exception(exception: Exception, *args, **kwargs) -> List[Any]:
140
+ return await _trigger_callbacks("agent_exception", exception, *args, **kwargs)
141
+
142
+
143
+ async def on_version_check(*args, **kwargs) -> List[Any]:
144
+ return await _trigger_callbacks("version_check", *args, **kwargs)
145
+
146
+
147
+ def on_load_model_config(*args, **kwargs) -> List[Any]:
148
+ return _trigger_callbacks_sync("load_model_config", *args, **kwargs)
149
+
150
+
151
+ def on_load_prompt():
152
+ return _trigger_callbacks_sync("load_prompt")