newcode 0.1.1__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 (289) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +147 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +630 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +122 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +380 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +167 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2145 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +296 -0
  28. code_puppy/agents/pack/husky.py +307 -0
  29. code_puppy/agents/pack/retriever.py +380 -0
  30. code_puppy/agents/pack/shepherd.py +327 -0
  31. code_puppy/agents/pack/terrier.py +281 -0
  32. code_puppy/agents/pack/watchdog.py +357 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +674 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +664 -0
  49. code_puppy/cli_runner.py +1038 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +526 -0
  57. code_puppy/command_line/command_handler.py +283 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +853 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +91 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/skills_completion.py +160 -0
  97. code_puppy/command_line/uc_menu.py +893 -0
  98. code_puppy/command_line/utils.py +93 -0
  99. code_puppy/command_line/wiggum_state.py +78 -0
  100. code_puppy/config.py +1787 -0
  101. code_puppy/error_logging.py +133 -0
  102. code_puppy/gemini_code_assist.py +385 -0
  103. code_puppy/gemini_model.py +754 -0
  104. code_puppy/hook_engine/README.md +105 -0
  105. code_puppy/hook_engine/__init__.py +15 -0
  106. code_puppy/hook_engine/aliases.py +155 -0
  107. code_puppy/hook_engine/engine.py +195 -0
  108. code_puppy/hook_engine/executor.py +293 -0
  109. code_puppy/hook_engine/matcher.py +145 -0
  110. code_puppy/hook_engine/models.py +222 -0
  111. code_puppy/hook_engine/registry.py +106 -0
  112. code_puppy/hook_engine/validator.py +141 -0
  113. code_puppy/http_utils.py +361 -0
  114. code_puppy/keymap.py +128 -0
  115. code_puppy/main.py +10 -0
  116. code_puppy/mcp_/__init__.py +66 -0
  117. code_puppy/mcp_/async_lifecycle.py +286 -0
  118. code_puppy/mcp_/blocking_startup.py +469 -0
  119. code_puppy/mcp_/captured_stdio_server.py +275 -0
  120. code_puppy/mcp_/circuit_breaker.py +290 -0
  121. code_puppy/mcp_/config_wizard.py +507 -0
  122. code_puppy/mcp_/dashboard.py +308 -0
  123. code_puppy/mcp_/error_isolation.py +407 -0
  124. code_puppy/mcp_/examples/retry_example.py +226 -0
  125. code_puppy/mcp_/health_monitor.py +589 -0
  126. code_puppy/mcp_/managed_server.py +428 -0
  127. code_puppy/mcp_/manager.py +807 -0
  128. code_puppy/mcp_/mcp_logs.py +224 -0
  129. code_puppy/mcp_/registry.py +451 -0
  130. code_puppy/mcp_/retry_manager.py +337 -0
  131. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  132. code_puppy/mcp_/status_tracker.py +355 -0
  133. code_puppy/mcp_/system_tools.py +209 -0
  134. code_puppy/mcp_prompts/__init__.py +1 -0
  135. code_puppy/mcp_prompts/hook_creator.py +103 -0
  136. code_puppy/messaging/__init__.py +255 -0
  137. code_puppy/messaging/bus.py +613 -0
  138. code_puppy/messaging/commands.py +167 -0
  139. code_puppy/messaging/markdown_patches.py +57 -0
  140. code_puppy/messaging/message_queue.py +361 -0
  141. code_puppy/messaging/messages.py +569 -0
  142. code_puppy/messaging/queue_console.py +271 -0
  143. code_puppy/messaging/renderers.py +311 -0
  144. code_puppy/messaging/rich_renderer.py +1153 -0
  145. code_puppy/messaging/spinner/__init__.py +83 -0
  146. code_puppy/messaging/spinner/console_spinner.py +240 -0
  147. code_puppy/messaging/spinner/spinner_base.py +96 -0
  148. code_puppy/messaging/subagent_console.py +460 -0
  149. code_puppy/model_factory.py +848 -0
  150. code_puppy/model_switching.py +63 -0
  151. code_puppy/model_utils.py +168 -0
  152. code_puppy/models.json +130 -0
  153. code_puppy/models_dev_api.json +1 -0
  154. code_puppy/models_dev_parser.py +592 -0
  155. code_puppy/plugins/__init__.py +186 -0
  156. code_puppy/plugins/agent_skills/__init__.py +22 -0
  157. code_puppy/plugins/agent_skills/config.py +175 -0
  158. code_puppy/plugins/agent_skills/discovery.py +136 -0
  159. code_puppy/plugins/agent_skills/downloader.py +392 -0
  160. code_puppy/plugins/agent_skills/installer.py +22 -0
  161. code_puppy/plugins/agent_skills/metadata.py +219 -0
  162. code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
  163. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  164. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  165. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  166. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  167. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  168. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  169. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  170. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  171. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  172. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  173. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  174. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  175. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  176. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  177. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  178. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  179. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  180. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  181. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  182. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  183. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  184. code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
  185. code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
  186. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  187. code_puppy/plugins/claude_code_hooks/config.py +131 -0
  188. code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
  189. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  190. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  191. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  192. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  193. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  194. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  195. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  196. code_puppy/plugins/claude_code_oauth/utils.py +601 -0
  197. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  198. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  199. code_puppy/plugins/example_custom_command/README.md +280 -0
  200. code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
  201. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  202. code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
  203. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  204. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  205. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  206. code_puppy/plugins/hook_creator/__init__.py +1 -0
  207. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  208. code_puppy/plugins/hook_manager/__init__.py +1 -0
  209. code_puppy/plugins/hook_manager/config.py +277 -0
  210. code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
  211. code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
  212. code_puppy/plugins/oauth_puppy_html.py +224 -0
  213. code_puppy/plugins/scheduler/__init__.py +1 -0
  214. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  215. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  216. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  217. code_puppy/plugins/shell_safety/__init__.py +6 -0
  218. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  219. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  220. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  221. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  222. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  223. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  224. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  225. code_puppy/plugins/universal_constructor/models.py +138 -0
  226. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  227. code_puppy/plugins/universal_constructor/registry.py +302 -0
  228. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  229. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  230. code_puppy/pydantic_patches.py +317 -0
  231. code_puppy/reopenable_async_client.py +232 -0
  232. code_puppy/round_robin_model.py +150 -0
  233. code_puppy/scheduler/__init__.py +41 -0
  234. code_puppy/scheduler/__main__.py +9 -0
  235. code_puppy/scheduler/cli.py +118 -0
  236. code_puppy/scheduler/config.py +126 -0
  237. code_puppy/scheduler/daemon.py +280 -0
  238. code_puppy/scheduler/executor.py +155 -0
  239. code_puppy/scheduler/platform.py +19 -0
  240. code_puppy/scheduler/platform_unix.py +22 -0
  241. code_puppy/scheduler/platform_win.py +32 -0
  242. code_puppy/session_storage.py +338 -0
  243. code_puppy/status_display.py +257 -0
  244. code_puppy/summarization_agent.py +176 -0
  245. code_puppy/terminal_utils.py +418 -0
  246. code_puppy/tools/__init__.py +470 -0
  247. code_puppy/tools/agent_tools.py +616 -0
  248. code_puppy/tools/ask_user_question/__init__.py +26 -0
  249. code_puppy/tools/ask_user_question/constants.py +73 -0
  250. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  251. code_puppy/tools/ask_user_question/handler.py +232 -0
  252. code_puppy/tools/ask_user_question/models.py +304 -0
  253. code_puppy/tools/ask_user_question/registration.py +36 -0
  254. code_puppy/tools/ask_user_question/renderers.py +309 -0
  255. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  256. code_puppy/tools/ask_user_question/theme.py +155 -0
  257. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  258. code_puppy/tools/browser/__init__.py +37 -0
  259. code_puppy/tools/browser/browser_control.py +289 -0
  260. code_puppy/tools/browser/browser_interactions.py +545 -0
  261. code_puppy/tools/browser/browser_locators.py +640 -0
  262. code_puppy/tools/browser/browser_manager.py +378 -0
  263. code_puppy/tools/browser/browser_navigation.py +251 -0
  264. code_puppy/tools/browser/browser_screenshot.py +179 -0
  265. code_puppy/tools/browser/browser_scripts.py +462 -0
  266. code_puppy/tools/browser/browser_workflows.py +221 -0
  267. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  268. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  269. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  270. code_puppy/tools/browser/terminal_tools.py +525 -0
  271. code_puppy/tools/command_runner.py +1346 -0
  272. code_puppy/tools/common.py +1409 -0
  273. code_puppy/tools/display.py +84 -0
  274. code_puppy/tools/file_modifications.py +739 -0
  275. code_puppy/tools/file_operations.py +802 -0
  276. code_puppy/tools/scheduler_tools.py +412 -0
  277. code_puppy/tools/skills_tools.py +251 -0
  278. code_puppy/tools/subagent_context.py +158 -0
  279. code_puppy/tools/tools_content.py +51 -0
  280. code_puppy/tools/universal_constructor.py +889 -0
  281. code_puppy/uvx_detection.py +242 -0
  282. code_puppy/version_checker.py +82 -0
  283. newcode-0.1.1.data/data/code_puppy/models.json +130 -0
  284. newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
  285. newcode-0.1.1.dist-info/METADATA +154 -0
  286. newcode-0.1.1.dist-info/RECORD +289 -0
  287. newcode-0.1.1.dist-info/WHEEL +4 -0
  288. newcode-0.1.1.dist-info/entry_points.txt +3 -0
  289. newcode-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,317 @@
1
+ """Monkey patches for pydantic-ai.
2
+
3
+ This module contains all monkey patches needed to customize pydantic-ai behavior.
4
+ These patches MUST be applied before any other pydantic-ai imports to work correctly.
5
+
6
+ Usage:
7
+ from code_puppy.pydantic_patches import apply_all_patches
8
+ apply_all_patches()
9
+ """
10
+
11
+ import importlib.metadata
12
+ from typing import Any
13
+
14
+
15
+ def _get_code_puppy_version() -> str:
16
+ """Get the current package version."""
17
+ try:
18
+ return importlib.metadata.version("newcode")
19
+ except Exception:
20
+ return "0.0.0-dev"
21
+
22
+
23
+ def patch_user_agent() -> None:
24
+ """Patch pydantic-ai's User-Agent to use our version.
25
+
26
+ pydantic-ai sets its own User-Agent ('pydantic-ai/x.x.x') via a @cache-decorated
27
+ function. We replace it with a dynamic function that returns:
28
+ - 'KimiCLI/0.63' for Kimi models
29
+ - 'NewCode/{version}' for all other models
30
+
31
+ This MUST be called before any pydantic-ai models are created.
32
+ """
33
+ try:
34
+ import pydantic_ai.models as pydantic_models
35
+
36
+ version = _get_code_puppy_version()
37
+
38
+ # Clear cache if already called
39
+ if hasattr(pydantic_models.get_user_agent, "cache_clear"):
40
+ pydantic_models.get_user_agent.cache_clear()
41
+
42
+ def _get_dynamic_user_agent() -> str:
43
+ """Return User-Agent based on current model selection."""
44
+ try:
45
+ from code_puppy.config import get_global_model_name
46
+
47
+ model_name = get_global_model_name()
48
+ if model_name and "kimi" in model_name.lower():
49
+ return "KimiCLI/0.63"
50
+ except Exception:
51
+ pass
52
+ return f"NewCode/{version}"
53
+
54
+ pydantic_models.get_user_agent = _get_dynamic_user_agent
55
+ except Exception:
56
+ pass # Don't crash on patch failure
57
+
58
+
59
+ def patch_message_history_cleaning() -> None:
60
+ """Disable overly strict message history cleaning in pydantic-ai."""
61
+ try:
62
+ from pydantic_ai import _agent_graph
63
+
64
+ _agent_graph._clean_message_history = lambda messages: messages
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ def patch_process_message_history() -> None:
70
+ """Patch _process_message_history to skip strict ModelRequest validation.
71
+
72
+ Pydantic AI added a validation that history must end with ModelRequest,
73
+ but this breaks valid conversation flows. We patch it to skip that validation.
74
+ """
75
+ try:
76
+ from pydantic_ai import _agent_graph
77
+
78
+ async def _patched_process_message_history(messages, processors, run_context):
79
+ """Patched version that doesn't enforce ModelRequest at end."""
80
+ from pydantic_ai._agent_graph import (
81
+ _HistoryProcessorAsync,
82
+ _HistoryProcessorSync,
83
+ _HistoryProcessorSyncWithCtx,
84
+ cast,
85
+ exceptions,
86
+ is_async_callable,
87
+ is_takes_ctx,
88
+ run_in_executor,
89
+ )
90
+
91
+ for processor in processors:
92
+ takes_ctx = is_takes_ctx(processor)
93
+
94
+ if is_async_callable(processor):
95
+ if takes_ctx:
96
+ messages = await processor(run_context, messages)
97
+ else:
98
+ async_processor = cast(_HistoryProcessorAsync, processor)
99
+ messages = await async_processor(messages)
100
+ else:
101
+ if takes_ctx:
102
+ sync_processor_with_ctx = cast(
103
+ _HistoryProcessorSyncWithCtx, processor
104
+ )
105
+ messages = await run_in_executor(
106
+ sync_processor_with_ctx, run_context, messages
107
+ )
108
+ else:
109
+ sync_processor = cast(_HistoryProcessorSync, processor)
110
+ messages = await run_in_executor(sync_processor, messages)
111
+
112
+ if len(messages) == 0:
113
+ raise exceptions.UserError("Processed history cannot be empty.")
114
+
115
+ # NOTE: We intentionally skip the "must end with ModelRequest" validation
116
+ # that was added in newer Pydantic AI versions.
117
+
118
+ return messages
119
+
120
+ _agent_graph._process_message_history = _patched_process_message_history
121
+ except Exception:
122
+ pass
123
+
124
+
125
+ def patch_tool_call_json_repair() -> None:
126
+ """Patch pydantic-ai's _call_tool to auto-repair malformed JSON arguments.
127
+
128
+ LLMs sometimes produce slightly broken JSON in tool calls (trailing commas,
129
+ missing quotes, etc.). This patch intercepts tool calls and runs json_repair
130
+ on the arguments before validation, preventing unnecessary retries.
131
+ """
132
+ try:
133
+ import json_repair
134
+ from pydantic_ai._tool_manager import ToolManager
135
+
136
+ # Store the original method
137
+ _original_call_tool = ToolManager._call_tool
138
+
139
+ async def _patched_call_tool(
140
+ self,
141
+ call,
142
+ *,
143
+ allow_partial: bool,
144
+ wrap_validation_errors: bool,
145
+ approved: bool,
146
+ metadata: Any = None,
147
+ ):
148
+ """Patched _call_tool that repairs malformed JSON before validation."""
149
+ # Only attempt repair if args is a string (JSON)
150
+ if isinstance(call.args, str) and call.args:
151
+ try:
152
+ repaired = json_repair.repair_json(call.args)
153
+ if repaired != call.args:
154
+ # Update the call args with repaired JSON
155
+ call.args = repaired
156
+ except Exception:
157
+ pass # If repair fails, let original validation handle it
158
+
159
+ # Call the original method
160
+ return await _original_call_tool(
161
+ self,
162
+ call,
163
+ allow_partial=allow_partial,
164
+ wrap_validation_errors=wrap_validation_errors,
165
+ approved=approved,
166
+ metadata=metadata,
167
+ )
168
+
169
+ # Apply the patch
170
+ ToolManager._call_tool = _patched_call_tool
171
+
172
+ except ImportError:
173
+ pass # json_repair or pydantic_ai not available
174
+ except Exception:
175
+ pass # Don't crash on patch failure
176
+
177
+
178
+ def patch_tool_call_callbacks() -> None:
179
+ """Patch pydantic-ai's _call_tool to fire pre/post tool callbacks.
180
+
181
+ This wraps ToolManager._call_tool so that every tool invocation
182
+ automatically triggers the ``pre_tool_call`` and ``post_tool_call``
183
+ callback hooks defined in ``code_puppy.callbacks``, without needing
184
+ to decorate each tool function individually.
185
+ """
186
+ import time
187
+
188
+ try:
189
+ from pydantic_ai._tool_manager import ToolManager
190
+
191
+ _original_call_tool = ToolManager._call_tool
192
+
193
+ # Tool name prefix used by Claude Code OAuth - tools are prefixed on
194
+ # outgoing requests, so we need to unprefix them when they come back
195
+ TOOL_PREFIX = "cp_"
196
+
197
+ async def _patched_call_tool(
198
+ self,
199
+ call,
200
+ *,
201
+ allow_partial: bool,
202
+ wrap_validation_errors: bool,
203
+ approved: bool,
204
+ metadata: Any = None,
205
+ ):
206
+ tool_name = call.tool_name
207
+
208
+ # Unprefix tool names from Claude Code OAuth responses
209
+ # The cp_ prefix is added for OAuth compatibility but needs to be
210
+ # stripped so pydantic-ai can find the actual tool
211
+ if tool_name and tool_name.startswith(TOOL_PREFIX):
212
+ unprefixed_name = tool_name[len(TOOL_PREFIX) :]
213
+ # Try to update the call object's tool_name
214
+ try:
215
+ call.tool_name = unprefixed_name
216
+ tool_name = unprefixed_name
217
+ except (AttributeError, TypeError):
218
+ # If the object is immutable, we can't modify it directly
219
+ # The tool lookup might still fail, but at least callbacks
220
+ # will use the unprefixed name
221
+ tool_name = unprefixed_name
222
+
223
+ # Normalise args to a dict for the callback contract
224
+ tool_args: dict = {}
225
+ if isinstance(call.args, dict):
226
+ tool_args = call.args
227
+ elif isinstance(call.args, str):
228
+ try:
229
+ import json
230
+
231
+ tool_args = json.loads(call.args)
232
+ except Exception:
233
+ tool_args = {"raw": call.args}
234
+
235
+ # --- pre_tool_call (with blocking support) ---
236
+ # Returns a string tool-result on block so pydantic-ai sees a clean
237
+ # "BLOCKED: ..." message and the agent can react gracefully, without
238
+ # triggering UnexpectedModelBehavior crashes.
239
+ try:
240
+ from code_puppy import callbacks
241
+ from code_puppy.messaging import emit_warning
242
+
243
+ callback_results = await callbacks.on_pre_tool_call(
244
+ tool_name, tool_args
245
+ )
246
+
247
+ for callback_result in callback_results:
248
+ if (
249
+ callback_result
250
+ and isinstance(callback_result, dict)
251
+ and callback_result.get("blocked")
252
+ ):
253
+ raw_reason = (
254
+ callback_result.get("error_message")
255
+ or callback_result.get("reason")
256
+ or ""
257
+ )
258
+ if "[BLOCKED]" in raw_reason:
259
+ clean_reason = raw_reason[
260
+ raw_reason.index("[BLOCKED]") :
261
+ ].strip()
262
+ else:
263
+ clean_reason = (
264
+ raw_reason.strip() or "Tool execution blocked by hook"
265
+ )
266
+ block_msg = f"🚫 Hook blocked this tool call: {clean_reason}"
267
+ emit_warning(block_msg)
268
+ return f"ERROR: {block_msg}\n\nThe hook policy prevented this tool from running. Please inform the user and do not retry this specific command."
269
+ except Exception:
270
+ pass # other errors don't block tool execution
271
+
272
+ start = time.perf_counter()
273
+ error: Exception | None = None
274
+ result = None
275
+ try:
276
+ result = await _original_call_tool(
277
+ self,
278
+ call,
279
+ allow_partial=allow_partial,
280
+ wrap_validation_errors=wrap_validation_errors,
281
+ approved=approved,
282
+ metadata=metadata,
283
+ )
284
+ return result
285
+ except Exception as exc:
286
+ error = exc
287
+ raise
288
+ finally:
289
+ duration_ms = (time.perf_counter() - start) * 1000
290
+ final_result = result if error is None else {"error": str(error)}
291
+ try:
292
+ from code_puppy import callbacks
293
+
294
+ await callbacks.on_post_tool_call(
295
+ tool_name, tool_args, final_result, duration_ms
296
+ )
297
+ except Exception:
298
+ pass # never block tool execution
299
+
300
+ ToolManager._call_tool = _patched_call_tool
301
+
302
+ except ImportError:
303
+ pass
304
+ except Exception:
305
+ pass
306
+
307
+
308
+ def apply_all_patches() -> None:
309
+ """Apply all pydantic-ai monkey patches.
310
+
311
+ Call this at the very top of main.py, before any other imports.
312
+ """
313
+ patch_user_agent()
314
+ patch_message_history_cleaning()
315
+ patch_process_message_history()
316
+ patch_tool_call_json_repair()
317
+ patch_tool_call_callbacks()
@@ -0,0 +1,232 @@
1
+ """
2
+ ReopenableAsyncClient - A reopenable httpx.AsyncClient wrapper.
3
+
4
+ This module provides a ReopenableAsyncClient class that extends httpx.AsyncClient
5
+ to support reopening after being closed, which the standard httpx.AsyncClient
6
+ doesn't support.
7
+ """
8
+
9
+ import asyncio
10
+ import threading
11
+ from typing import Optional, Union
12
+
13
+ import httpx
14
+
15
+
16
+ class ReopenableAsyncClient:
17
+ """
18
+ A wrapper around httpx.AsyncClient that can be reopened after being closed.
19
+
20
+ Standard httpx.AsyncClient becomes unusable after calling aclose().
21
+ This class allows you to reopen the client and continue using it.
22
+
23
+ Example:
24
+ >>> client = ReopenableAsyncClient(timeout=30.0)
25
+ >>> await client.get("https://httpbin.org/get")
26
+ >>> await client.aclose()
27
+ >>> # Client is now closed, but can be reopened
28
+ >>> await client.reopen()
29
+ >>> await client.get("https://httpbin.org/get") # Works!
30
+
31
+ The client preserves all original configuration when reopening.
32
+ """
33
+
34
+ class _StreamWrapper:
35
+ """Async context manager wrapper for streaming responses."""
36
+
37
+ def __init__(
38
+ self,
39
+ parent_client: "ReopenableAsyncClient",
40
+ method: str,
41
+ url: Union[str, httpx.URL],
42
+ **kwargs,
43
+ ):
44
+ self.parent_client = parent_client
45
+ self.method = method
46
+ self.url = url
47
+ self.kwargs = kwargs
48
+ self._stream_context = None
49
+
50
+ async def __aenter__(self):
51
+ client = await self.parent_client._ensure_client_open()
52
+ self._stream_context = client.stream(self.method, self.url, **self.kwargs)
53
+ return await self._stream_context.__aenter__()
54
+
55
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
56
+ if self._stream_context:
57
+ return await self._stream_context.__aexit__(exc_type, exc_val, exc_tb)
58
+
59
+ def __init__(self, client_class=None, **kwargs):
60
+ """
61
+ Initialize the ReopenableAsyncClient.
62
+
63
+ Args:
64
+ client_class: Class to use for creating the internal client (defaults to httpx.AsyncClient)
65
+ **kwargs: All arguments that would be passed to the client constructor
66
+ """
67
+ self._client_class = client_class or httpx.AsyncClient
68
+ self._client_kwargs = kwargs.copy()
69
+ self._client: Optional[httpx.AsyncClient] = None
70
+ self._is_closed = True
71
+ self._lock = asyncio.Lock()
72
+ self._sync_lock = threading.Lock()
73
+
74
+ async def _ensure_client_open(self) -> httpx.AsyncClient:
75
+ """
76
+ Ensure the underlying client is open and ready to use.
77
+
78
+ Returns:
79
+ The active client instance
80
+
81
+ Raises:
82
+ RuntimeError: If client cannot be opened
83
+ """
84
+ async with self._lock:
85
+ if self._is_closed or self._client is None:
86
+ await self._create_client()
87
+ return self._client
88
+
89
+ async def _create_client(self) -> None:
90
+ """Create a new client with the stored configuration."""
91
+ if self._client is not None and not self._is_closed:
92
+ # Close existing client first
93
+ await self._client.aclose()
94
+
95
+ self._client = self._client_class(**self._client_kwargs)
96
+ self._is_closed = False
97
+
98
+ async def reopen(self) -> None:
99
+ """
100
+ Explicitly reopen the client after it has been closed.
101
+
102
+ This is useful when you want to reuse a client that was previously closed.
103
+ """
104
+ async with self._lock:
105
+ await self._create_client()
106
+
107
+ async def aclose(self) -> None:
108
+ """
109
+ Close the underlying httpx.AsyncClient.
110
+
111
+ After calling this, the client can be reopened using reopen() or
112
+ automatically when making the next request.
113
+ """
114
+ async with self._lock:
115
+ if self._client is not None and not self._is_closed:
116
+ await self._client.aclose()
117
+ self._is_closed = True
118
+
119
+ @property
120
+ def is_closed(self) -> bool:
121
+ """Check if the client is currently closed."""
122
+ return self._is_closed or self._client is None
123
+
124
+ # Delegate all httpx.AsyncClient methods to the underlying client
125
+
126
+ async def get(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
127
+ """Make a GET request."""
128
+ client = await self._ensure_client_open()
129
+ return await client.get(url, **kwargs)
130
+
131
+ async def post(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
132
+ """Make a POST request."""
133
+ client = await self._ensure_client_open()
134
+ return await client.post(url, **kwargs)
135
+
136
+ async def put(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
137
+ """Make a PUT request."""
138
+ client = await self._ensure_client_open()
139
+ return await client.put(url, **kwargs)
140
+
141
+ async def patch(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
142
+ """Make a PATCH request."""
143
+ client = await self._ensure_client_open()
144
+ return await client.patch(url, **kwargs)
145
+
146
+ async def delete(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
147
+ """Make a DELETE request."""
148
+ client = await self._ensure_client_open()
149
+ return await client.delete(url, **kwargs)
150
+
151
+ async def head(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
152
+ """Make a HEAD request."""
153
+ client = await self._ensure_client_open()
154
+ return await client.head(url, **kwargs)
155
+
156
+ async def options(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
157
+ """Make an OPTIONS request."""
158
+ client = await self._ensure_client_open()
159
+ return await client.options(url, **kwargs)
160
+
161
+ async def request(
162
+ self, method: str, url: Union[str, httpx.URL], **kwargs
163
+ ) -> httpx.Response:
164
+ """Make a request with the specified HTTP method."""
165
+ client = await self._ensure_client_open()
166
+ return await client.request(method, url, **kwargs)
167
+
168
+ async def send(self, request: httpx.Request, **kwargs) -> httpx.Response:
169
+ """Send a pre-built request."""
170
+ client = await self._ensure_client_open()
171
+ return await client.send(request, **kwargs)
172
+
173
+ def build_request(
174
+ self, method: str, url: Union[str, httpx.URL], **kwargs
175
+ ) -> httpx.Request:
176
+ """
177
+ Build a request without sending it.
178
+
179
+ Note: This creates a temporary client if none exists, but doesn't keep it open.
180
+ """
181
+ with self._sync_lock:
182
+ if self._client is None or self._is_closed:
183
+ # Create temporary sync client for building request only
184
+ # Use httpx.Client (sync) so we can properly close it
185
+ temp_client = httpx.Client(**self._client_kwargs)
186
+ try:
187
+ return temp_client.build_request(method, url, **kwargs)
188
+ finally:
189
+ temp_client.close()
190
+ return self._client.build_request(method, url, **kwargs)
191
+
192
+ def stream(self, method: str, url: Union[str, httpx.URL], **kwargs):
193
+ """Stream a request. Returns an async context manager."""
194
+ return self._StreamWrapper(self, method, url, **kwargs)
195
+
196
+ # Context manager support
197
+ async def __aenter__(self):
198
+ """Async context manager entry."""
199
+ await self._ensure_client_open()
200
+ return self
201
+
202
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
203
+ """Async context manager exit."""
204
+ await self.aclose()
205
+
206
+ # Properties that don't require an active client
207
+ @property
208
+ def timeout(self) -> Optional[httpx.Timeout]:
209
+ """Get the configured timeout."""
210
+ return self._client_kwargs.get("timeout")
211
+
212
+ @property
213
+ def headers(self) -> httpx.Headers:
214
+ """Get the configured headers."""
215
+ if self._client is not None:
216
+ return self._client.headers
217
+ # Return headers from kwargs if client doesn't exist
218
+ headers = self._client_kwargs.get("headers", {})
219
+ return httpx.Headers(headers)
220
+
221
+ @property
222
+ def cookies(self) -> httpx.Cookies:
223
+ """Get the current cookies."""
224
+ if self._client is not None and not self._is_closed:
225
+ return self._client.cookies
226
+ # Return empty cookies if client doesn't exist or is closed
227
+ return httpx.Cookies()
228
+
229
+ def __repr__(self) -> str:
230
+ """String representation of the client."""
231
+ status = "closed" if self.is_closed else "open"
232
+ return f"<ReopenableAsyncClient [{status}]>"
@@ -0,0 +1,150 @@
1
+ import threading
2
+ from contextlib import asynccontextmanager, suppress
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, AsyncIterator, List
5
+
6
+ from pydantic_ai._run_context import RunContext
7
+ from pydantic_ai.models import (
8
+ Model,
9
+ ModelMessage,
10
+ ModelRequestParameters,
11
+ ModelResponse,
12
+ ModelSettings,
13
+ StreamedResponse,
14
+ )
15
+
16
+ try:
17
+ from opentelemetry.context import get_current_span
18
+ except ImportError:
19
+ # If opentelemetry is not installed, provide a dummy implementation
20
+ def get_current_span():
21
+ class DummySpan:
22
+ def is_recording(self):
23
+ return False
24
+
25
+ def set_attributes(self, attributes):
26
+ pass
27
+
28
+ return DummySpan()
29
+
30
+
31
+ @dataclass(init=False)
32
+ class RoundRobinModel(Model):
33
+ """A model that cycles through multiple models in a round-robin fashion.
34
+
35
+ This model distributes requests across multiple candidate models to help
36
+ overcome rate limits or distribute load.
37
+ """
38
+
39
+ models: List[Model]
40
+ _current_index: int = field(default=0, repr=False)
41
+ _model_name: str = field(repr=False)
42
+ _rotate_every: int = field(default=1, repr=False)
43
+ _request_count: int = field(default=0, repr=False)
44
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
45
+
46
+ def __init__(
47
+ self,
48
+ *models: Model,
49
+ rotate_every: int = 1,
50
+ settings: ModelSettings | None = None,
51
+ ):
52
+ """Initialize a round-robin model instance.
53
+
54
+ Args:
55
+ models: The model instances to cycle through.
56
+ rotate_every: Number of requests before rotating to the next model (default: 1).
57
+ settings: Model settings that will be used as defaults for this model.
58
+ """
59
+ super().__init__(settings=settings)
60
+ if not models:
61
+ raise ValueError("At least one model must be provided")
62
+ if rotate_every < 1:
63
+ raise ValueError("rotate_every must be at least 1")
64
+ self.models = list(models)
65
+ self._current_index = 0
66
+ self._request_count = 0
67
+ self._rotate_every = rotate_every
68
+ self._lock = threading.Lock()
69
+
70
+ @property
71
+ def model_name(self) -> str:
72
+ """The model name showing this is a round-robin model with its candidates."""
73
+ base_name = f"round_robin:{','.join(model.model_name for model in self.models)}"
74
+ if self._rotate_every != 1:
75
+ return f"{base_name}:rotate_every={self._rotate_every}"
76
+ return base_name
77
+
78
+ @property
79
+ def system(self) -> str:
80
+ """System prompt from the current model."""
81
+ return self.models[self._current_index].system
82
+
83
+ @property
84
+ def base_url(self) -> str | None:
85
+ """Base URL from the current model."""
86
+ return self.models[self._current_index].base_url
87
+
88
+ def _get_next_model(self) -> Model:
89
+ """Get the next model in the round-robin sequence and update the index."""
90
+ with self._lock:
91
+ model = self.models[self._current_index]
92
+ self._request_count += 1
93
+ if self._request_count >= self._rotate_every:
94
+ self._current_index = (self._current_index + 1) % len(self.models)
95
+ self._request_count = 0
96
+ return model
97
+
98
+ async def request(
99
+ self,
100
+ messages: list[ModelMessage],
101
+ model_settings: ModelSettings | None,
102
+ model_request_parameters: ModelRequestParameters,
103
+ ) -> ModelResponse:
104
+ """Make a request using the next model in the round-robin sequence."""
105
+ current_model = self._get_next_model()
106
+ # Use prepare_request to merge settings and customize parameters
107
+ merged_settings, prepared_params = current_model.prepare_request(
108
+ model_settings, model_request_parameters
109
+ )
110
+
111
+ try:
112
+ response = await current_model.request(
113
+ messages, merged_settings, prepared_params
114
+ )
115
+ self._set_span_attributes(current_model)
116
+ return response
117
+ except Exception:
118
+ # Unlike FallbackModel, we don't try other models here
119
+ # The round-robin strategy is about distribution, not failover
120
+ raise
121
+
122
+ @asynccontextmanager
123
+ async def request_stream(
124
+ self,
125
+ messages: list[ModelMessage],
126
+ model_settings: ModelSettings | None,
127
+ model_request_parameters: ModelRequestParameters,
128
+ run_context: RunContext[Any] | None = None,
129
+ ) -> AsyncIterator[StreamedResponse]:
130
+ """Make a streaming request using the next model in the round-robin sequence."""
131
+ current_model = self._get_next_model()
132
+ # Use prepare_request to merge settings and customize parameters
133
+ merged_settings, prepared_params = current_model.prepare_request(
134
+ model_settings, model_request_parameters
135
+ )
136
+
137
+ async with current_model.request_stream(
138
+ messages, merged_settings, prepared_params, run_context
139
+ ) as response:
140
+ self._set_span_attributes(current_model)
141
+ yield response
142
+
143
+ def _set_span_attributes(self, model: Model):
144
+ """Set span attributes for observability."""
145
+ with suppress(Exception):
146
+ span = get_current_span()
147
+ if span.is_recording():
148
+ attributes = getattr(span, "attributes", {})
149
+ if attributes.get("gen_ai.request.model") == self.model_name:
150
+ span.set_attributes(model.model_attributes(model))