code-puppy 0.0.372__py3-none-any.whl → 0.0.374__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 (30) hide show
  1. code_puppy/agents/agent_creator_agent.py +49 -1
  2. code_puppy/agents/agent_helios.py +122 -0
  3. code_puppy/agents/agent_manager.py +26 -2
  4. code_puppy/agents/json_agent.py +30 -7
  5. code_puppy/claude_cache_client.py +9 -9
  6. code_puppy/command_line/colors_menu.py +2 -0
  7. code_puppy/command_line/command_handler.py +1 -0
  8. code_puppy/command_line/config_commands.py +3 -1
  9. code_puppy/command_line/uc_menu.py +890 -0
  10. code_puppy/config.py +29 -0
  11. code_puppy/messaging/messages.py +18 -0
  12. code_puppy/messaging/rich_renderer.py +35 -0
  13. code_puppy/messaging/subagent_console.py +0 -1
  14. code_puppy/plugins/claude_code_oauth/README.md +1 -1
  15. code_puppy/plugins/claude_code_oauth/SETUP.md +1 -1
  16. code_puppy/plugins/claude_code_oauth/utils.py +44 -13
  17. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  18. code_puppy/plugins/universal_constructor/models.py +138 -0
  19. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  20. code_puppy/plugins/universal_constructor/registry.py +304 -0
  21. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  22. code_puppy/tools/__init__.py +138 -1
  23. code_puppy/tools/universal_constructor.py +889 -0
  24. {code_puppy-0.0.372.dist-info → code_puppy-0.0.374.dist-info}/METADATA +1 -1
  25. {code_puppy-0.0.372.dist-info → code_puppy-0.0.374.dist-info}/RECORD +30 -22
  26. {code_puppy-0.0.372.data → code_puppy-0.0.374.data}/data/code_puppy/models.json +0 -0
  27. {code_puppy-0.0.372.data → code_puppy-0.0.374.data}/data/code_puppy/models_dev_api.json +0 -0
  28. {code_puppy-0.0.372.dist-info → code_puppy-0.0.374.dist-info}/WHEEL +0 -0
  29. {code_puppy-0.0.372.dist-info → code_puppy-0.0.374.dist-info}/entry_points.txt +0 -0
  30. {code_puppy-0.0.372.dist-info → code_puppy-0.0.374.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,889 @@
1
+ """Universal Constructor Tool - Dynamic tool creation and management.
2
+
3
+ This module provides the universal_constructor tool that enables users
4
+ to create, manage, and call custom tools dynamically during a session.
5
+ """
6
+
7
+ import subprocess
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from concurrent.futures import TimeoutError as FuturesTimeoutError
11
+ from typing import Literal, Optional
12
+
13
+ from pydantic import BaseModel, Field
14
+ from pydantic_ai import RunContext
15
+
16
+ from code_puppy.messaging import get_message_bus
17
+ from code_puppy.messaging.messages import UniversalConstructorMessage
18
+ from code_puppy.plugins.universal_constructor.models import (
19
+ UCCallOutput,
20
+ UCCreateOutput,
21
+ UCInfoOutput,
22
+ UCListOutput,
23
+ UCUpdateOutput,
24
+ )
25
+
26
+
27
+ class UniversalConstructorOutput(BaseModel):
28
+ """Unified response model for universal_constructor operations.
29
+
30
+ Wraps all action-specific outputs with a common interface.
31
+ """
32
+
33
+ action: str = Field(..., description="The action that was performed")
34
+ success: bool = Field(..., description="Whether the operation succeeded")
35
+ error: Optional[str] = Field(default=None, description="Error message if failed")
36
+
37
+ # Action-specific results (only one will be populated based on action)
38
+ list_result: Optional[UCListOutput] = Field(
39
+ default=None, description="Result of list action"
40
+ )
41
+ call_result: Optional[UCCallOutput] = Field(
42
+ default=None, description="Result of call action"
43
+ )
44
+ create_result: Optional[UCCreateOutput] = Field(
45
+ default=None, description="Result of create action"
46
+ )
47
+ update_result: Optional[UCUpdateOutput] = Field(
48
+ default=None, description="Result of update action"
49
+ )
50
+ info_result: Optional[UCInfoOutput] = Field(
51
+ default=None, description="Result of info action"
52
+ )
53
+
54
+ model_config = {"arbitrary_types_allowed": True}
55
+
56
+
57
+ def _stub_not_implemented(action: str) -> UniversalConstructorOutput:
58
+ """Return a stub response for unimplemented actions."""
59
+ return UniversalConstructorOutput(
60
+ action=action,
61
+ success=False,
62
+ error="Not implemented yet",
63
+ )
64
+
65
+
66
+ def _run_ruff_format(file_path) -> Optional[str]:
67
+ """Run ruff format on a file.
68
+
69
+ Args:
70
+ file_path: Path to the file to format (str or Path)
71
+
72
+ Returns:
73
+ Warning message if formatting failed, None on success
74
+ """
75
+ try:
76
+ result = subprocess.run(
77
+ ["ruff", "format", str(file_path)],
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=10,
81
+ )
82
+ if result.returncode != 0:
83
+ return f"ruff format failed: {result.stderr.strip()}"
84
+ return None
85
+ except FileNotFoundError:
86
+ return "ruff not found - code not formatted"
87
+ except subprocess.TimeoutExpired:
88
+ return "ruff format timed out"
89
+ except Exception as e:
90
+ return f"ruff format error: {e}"
91
+
92
+
93
+ def _generate_preview(code: str, max_lines: int = 10) -> str:
94
+ """Generate a preview of the first N lines of code.
95
+
96
+ Args:
97
+ code: The source code to preview
98
+ max_lines: Maximum number of lines to include (default 10)
99
+
100
+ Returns:
101
+ A string with the first N lines, with "..." appended if truncated
102
+ """
103
+ lines = code.splitlines()
104
+ if len(lines) <= max_lines:
105
+ return code
106
+ preview_lines = lines[:max_lines]
107
+ return "\n".join(preview_lines) + "\n... (truncated)"
108
+
109
+
110
+ def _emit_uc_message(
111
+ action: str,
112
+ success: bool,
113
+ summary: str,
114
+ tool_name: Optional[str] = None,
115
+ details: Optional[str] = None,
116
+ ) -> None:
117
+ """Emit a UniversalConstructorMessage to the message bus.
118
+
119
+ Args:
120
+ action: The UC action performed (list/call/create/update/info)
121
+ success: Whether the operation succeeded
122
+ summary: Brief summary of the result
123
+ tool_name: Tool name if applicable
124
+ details: Additional details (optional)
125
+ """
126
+ bus = get_message_bus()
127
+ msg = UniversalConstructorMessage(
128
+ action=action,
129
+ tool_name=tool_name,
130
+ success=success,
131
+ summary=summary,
132
+ details=details,
133
+ )
134
+ bus.emit(msg)
135
+
136
+
137
+ async def universal_constructor_impl(
138
+ context: RunContext,
139
+ action: Literal["list", "call", "create", "update", "info"],
140
+ tool_name: Optional[str] = None,
141
+ tool_args: Optional[dict] = None,
142
+ python_code: Optional[str] = None,
143
+ description: Optional[str] = None,
144
+ ) -> UniversalConstructorOutput:
145
+ """Implementation of the universal_constructor tool.
146
+
147
+ Routes to appropriate action handler based on the action parameter.
148
+ All actions are currently stubbed out and will return "Not implemented yet".
149
+
150
+ Args:
151
+ context: The run context from pydantic-ai
152
+ action: The operation to perform:
153
+ - "list": List all available UC tools
154
+ - "call": Execute a specific UC tool
155
+ - "create": Create a new UC tool from Python code
156
+ - "update": Modify an existing UC tool
157
+ - "info": Get detailed info about a specific tool
158
+ tool_name: Name of tool (for call/update/info). Supports "namespace.name" format.
159
+ tool_args: Arguments to pass when calling a tool (for call action)
160
+ python_code: Python source code for the tool (for create/update actions)
161
+ description: Human-readable description (for create action)
162
+
163
+ Returns:
164
+ UniversalConstructorOutput with action-specific results
165
+ """
166
+ # Route to appropriate action handler
167
+ if action == "list":
168
+ result = _handle_list_action(context)
169
+ elif action == "call":
170
+ result = _handle_call_action(context, tool_name, tool_args)
171
+ elif action == "create":
172
+ result = _handle_create_action(context, tool_name, python_code, description)
173
+ elif action == "update":
174
+ result = _handle_update_action(context, tool_name, python_code, description)
175
+ elif action == "info":
176
+ result = _handle_info_action(context, tool_name)
177
+ else:
178
+ result = UniversalConstructorOutput(
179
+ action=action,
180
+ success=False,
181
+ error=f"Unknown action: {action}",
182
+ )
183
+
184
+ # Emit the banner message after the action completes
185
+ summary = _build_summary(result)
186
+ _emit_uc_message(
187
+ action=action,
188
+ success=result.success,
189
+ summary=summary,
190
+ tool_name=tool_name,
191
+ details=result.error if not result.success else None,
192
+ )
193
+
194
+ return result
195
+
196
+
197
+ def _build_summary(result: UniversalConstructorOutput) -> str:
198
+ """Build a brief summary string from a UC result.
199
+
200
+ Args:
201
+ result: The UniversalConstructorOutput to summarize
202
+
203
+ Returns:
204
+ A brief human-readable summary string
205
+ """
206
+ if not result.success:
207
+ return result.error or "Operation failed"
208
+
209
+ if result.list_result:
210
+ return f"Found {result.list_result.enabled_count} enabled tools (of {result.list_result.total_count} total)"
211
+ elif result.call_result:
212
+ exec_time = result.call_result.execution_time or 0
213
+ return f"Executed in {exec_time:.2f}s"
214
+ elif result.create_result:
215
+ return f"Created {result.create_result.tool_name}"
216
+ elif result.update_result:
217
+ return f"Updated {result.update_result.tool_name}"
218
+ elif result.info_result and result.info_result.tool:
219
+ return f"Info for {result.info_result.tool.full_name}"
220
+ else:
221
+ return "Operation completed"
222
+
223
+
224
+ def _handle_list_action(context: RunContext) -> UniversalConstructorOutput:
225
+ """Handle the 'list' action - list all available UC tools.
226
+
227
+ Lists all enabled tools from the UC registry, returning their
228
+ metadata, signatures, and source paths.
229
+
230
+ Args:
231
+ context: The run context from pydantic-ai (unused for list action)
232
+
233
+ Returns:
234
+ UniversalConstructorOutput with list_result containing all enabled tools.
235
+ """
236
+ from code_puppy.plugins.universal_constructor.registry import get_registry
237
+
238
+ try:
239
+ registry = get_registry()
240
+ # Get all tools (including disabled for count)
241
+ all_tools = registry.list_tools(include_disabled=True)
242
+ enabled_tools = [t for t in all_tools if t.meta.enabled]
243
+
244
+ return UniversalConstructorOutput(
245
+ action="list",
246
+ success=True,
247
+ list_result=UCListOutput(
248
+ tools=enabled_tools,
249
+ total_count=len(all_tools),
250
+ enabled_count=len(enabled_tools),
251
+ ),
252
+ )
253
+ except Exception as e:
254
+ return UniversalConstructorOutput(
255
+ action="list",
256
+ success=False,
257
+ error=f"Failed to list tools: {e}",
258
+ list_result=UCListOutput(
259
+ tools=[],
260
+ total_count=0,
261
+ enabled_count=0,
262
+ error=str(e),
263
+ ),
264
+ )
265
+
266
+
267
+ def _handle_call_action(
268
+ context: RunContext,
269
+ tool_name: Optional[str],
270
+ tool_args: Optional[dict],
271
+ ) -> UniversalConstructorOutput:
272
+ """Handle the 'call' action - execute a UC tool.
273
+
274
+ Validates the tool exists and is enabled, then executes it with a timeout.
275
+
276
+ Args:
277
+ context: The run context from pydantic-ai
278
+ tool_name: Name of the tool to call (required)
279
+ tool_args: Arguments to pass to the tool function
280
+
281
+ Returns:
282
+ UniversalConstructorOutput with call_result on success or error on failure
283
+ """
284
+ if not tool_name:
285
+ return UniversalConstructorOutput(
286
+ action="call",
287
+ success=False,
288
+ error="tool_name is required for call action",
289
+ )
290
+
291
+ from code_puppy.plugins.universal_constructor.registry import get_registry
292
+
293
+ registry = get_registry()
294
+ tool = registry.get_tool(tool_name)
295
+
296
+ if not tool:
297
+ return UniversalConstructorOutput(
298
+ action="call",
299
+ success=False,
300
+ error=f"Tool '{tool_name}' not found",
301
+ )
302
+
303
+ if not tool.meta.enabled:
304
+ return UniversalConstructorOutput(
305
+ action="call",
306
+ success=False,
307
+ error=f"Tool '{tool_name}' is disabled",
308
+ )
309
+
310
+ # Read source for preview
311
+ source_preview = None
312
+ if tool.source_path:
313
+ try:
314
+ from pathlib import Path
315
+
316
+ source_code = Path(tool.source_path).read_text(encoding="utf-8")
317
+ source_preview = _generate_preview(source_code)
318
+ except Exception:
319
+ pass # Preview is optional, don't fail on read errors
320
+
321
+ func = registry.get_tool_function(tool_name)
322
+ if not func:
323
+ return UniversalConstructorOutput(
324
+ action="call",
325
+ success=False,
326
+ error=f"Could not load function for '{tool_name}'",
327
+ )
328
+
329
+ # Handle tool_args being passed as a JSON string (XML marshaling issue)
330
+ args = tool_args or {}
331
+ if isinstance(args, str):
332
+ try:
333
+ import json
334
+
335
+ args = json.loads(args)
336
+ except json.JSONDecodeError:
337
+ return UniversalConstructorOutput(
338
+ action="call",
339
+ success=False,
340
+ error=f"Invalid tool_args: expected dict or JSON string, got: {args[:100]}",
341
+ )
342
+ if not isinstance(args, dict):
343
+ return UniversalConstructorOutput(
344
+ action="call",
345
+ success=False,
346
+ error=f"tool_args must be a dict, got {type(args).__name__}",
347
+ )
348
+ start_time = time.time()
349
+
350
+ try:
351
+ # Execute with timeout using ThreadPoolExecutor
352
+ with ThreadPoolExecutor(max_workers=1) as executor:
353
+ future = executor.submit(func, **args)
354
+ result = future.result(timeout=30)
355
+
356
+ execution_time = time.time() - start_time
357
+
358
+ return UniversalConstructorOutput(
359
+ action="call",
360
+ success=True,
361
+ call_result=UCCallOutput(
362
+ success=True,
363
+ tool_name=tool_name,
364
+ result=result,
365
+ execution_time=execution_time,
366
+ source_preview=source_preview,
367
+ ),
368
+ )
369
+ except FuturesTimeoutError:
370
+ return UniversalConstructorOutput(
371
+ action="call",
372
+ success=False,
373
+ error=f"Tool '{tool_name}' timed out after 30s",
374
+ )
375
+ except TypeError as e:
376
+ # Invalid arguments
377
+ return UniversalConstructorOutput(
378
+ action="call",
379
+ success=False,
380
+ error=f"Invalid arguments for '{tool_name}': {e!s}",
381
+ )
382
+ except Exception as e:
383
+ return UniversalConstructorOutput(
384
+ action="call",
385
+ success=False,
386
+ error=f"Tool execution failed: {e!s}",
387
+ )
388
+
389
+
390
+ def _handle_create_action(
391
+ context: RunContext,
392
+ tool_name: Optional[str],
393
+ python_code: Optional[str],
394
+ description: Optional[str],
395
+ ) -> UniversalConstructorOutput:
396
+ """Handle the 'create' action - create a new UC tool.
397
+
398
+ Creates a new tool from Python source code. The code can either include
399
+ a TOOL_META dictionary, or one will be generated from the provided
400
+ tool_name and description parameters.
401
+
402
+ Supports namespacing via dot notation in tool_name:
403
+ - "weather" → weather.py
404
+ - "api.weather" → api/weather.py
405
+ - "api.finance.stocks" → api/finance/stocks.py
406
+
407
+ Args:
408
+ context: The run context from pydantic-ai
409
+ tool_name: Name of the tool (with optional namespace). Required if
410
+ code doesn't contain TOOL_META.
411
+ python_code: Python source code defining the tool function (required)
412
+ description: Description of what the tool does. Used if no TOOL_META
413
+ in code.
414
+
415
+ Returns:
416
+ UniversalConstructorOutput with create_result on success
417
+ """
418
+ from datetime import datetime
419
+ from pathlib import Path
420
+
421
+ from code_puppy.plugins.universal_constructor import USER_UC_DIR
422
+ from code_puppy.plugins.universal_constructor.registry import get_registry
423
+ from code_puppy.plugins.universal_constructor.sandbox import (
424
+ _extract_tool_meta,
425
+ _validate_tool_meta,
426
+ check_dangerous_patterns,
427
+ extract_function_info,
428
+ validate_syntax,
429
+ )
430
+
431
+ # Validate python_code is provided
432
+ if not python_code or not python_code.strip():
433
+ return UniversalConstructorOutput(
434
+ action="create",
435
+ success=False,
436
+ error="python_code is required for create action",
437
+ )
438
+
439
+ # Validate syntax
440
+ syntax_result = validate_syntax(python_code)
441
+ if not syntax_result.valid:
442
+ error_msg = "; ".join(syntax_result.errors)
443
+ return UniversalConstructorOutput(
444
+ action="create",
445
+ success=False,
446
+ error=f"Syntax error in code: {error_msg}",
447
+ )
448
+
449
+ # Extract function info
450
+ func_result = extract_function_info(python_code)
451
+ if not func_result.functions:
452
+ return UniversalConstructorOutput(
453
+ action="create",
454
+ success=False,
455
+ error="No functions found in code - tool must have at least one function",
456
+ )
457
+
458
+ # Get the first function as the main tool function
459
+ main_func = func_result.functions[0]
460
+
461
+ # Try to extract TOOL_META from code
462
+ existing_meta = _extract_tool_meta(python_code)
463
+
464
+ # Determine final tool name and namespace
465
+ if existing_meta and "name" in existing_meta:
466
+ # Use name from TOOL_META
467
+ final_name = existing_meta["name"]
468
+ final_namespace = existing_meta.get("namespace", "")
469
+ elif tool_name:
470
+ # Parse namespace from tool_name (e.g., "api.weather" → namespace="api", name="weather")
471
+ parts = tool_name.rsplit(".", 1)
472
+ if len(parts) == 2:
473
+ final_namespace, final_name = parts[0], parts[1]
474
+ else:
475
+ final_namespace, final_name = "", parts[0]
476
+ else:
477
+ # Use function name as tool name
478
+ final_name = main_func.name
479
+ final_namespace = ""
480
+
481
+ # Validate we have a name
482
+ if not final_name:
483
+ return UniversalConstructorOutput(
484
+ action="create",
485
+ success=False,
486
+ error="Could not determine tool name - provide tool_name or include TOOL_META in code",
487
+ )
488
+
489
+ # Build file path based on namespace
490
+ if final_namespace:
491
+ # Convert dot notation to path (api.finance → api/finance/)
492
+ namespace_path = Path(*final_namespace.split("."))
493
+ file_dir = USER_UC_DIR / namespace_path
494
+ else:
495
+ file_dir = USER_UC_DIR
496
+
497
+ file_path = file_dir / f"{final_name}.py"
498
+
499
+ # Build the final code to write
500
+ validation_warnings = []
501
+
502
+ if existing_meta:
503
+ # Validate existing TOOL_META has required fields
504
+ meta_errors = _validate_tool_meta(existing_meta)
505
+ if meta_errors:
506
+ return UniversalConstructorOutput(
507
+ action="create",
508
+ success=False,
509
+ error="Invalid TOOL_META: " + "; ".join(meta_errors),
510
+ )
511
+ # Code already has TOOL_META, use as-is
512
+ final_code = python_code
513
+ # Collect any validation warnings
514
+ validation_warnings.extend(func_result.warnings)
515
+ else:
516
+ # Generate TOOL_META and prepend to code
517
+ final_description = description or main_func.docstring or f"Tool: {final_name}"
518
+
519
+ generated_meta = {
520
+ "name": final_name,
521
+ "namespace": final_namespace,
522
+ "description": final_description,
523
+ "enabled": True,
524
+ "version": "1.0.0",
525
+ "author": "user",
526
+ "created_at": datetime.now().isoformat(),
527
+ }
528
+
529
+ # Format TOOL_META as a dict literal
530
+ meta_str = f"TOOL_META = {repr(generated_meta)}\n\n"
531
+ final_code = meta_str + python_code
532
+ validation_warnings.append("TOOL_META was auto-generated")
533
+ validation_warnings.extend(func_result.warnings)
534
+
535
+ # Check for dangerous patterns (warning only, don't block)
536
+ safety_result = check_dangerous_patterns(python_code)
537
+ validation_warnings.extend(safety_result.warnings)
538
+
539
+ # Ensure directory exists and write file
540
+ try:
541
+ file_dir.mkdir(parents=True, exist_ok=True)
542
+ file_path.write_text(final_code, encoding="utf-8")
543
+ except Exception as e:
544
+ return UniversalConstructorOutput(
545
+ action="create",
546
+ success=False,
547
+ error=f"Failed to write tool file: {e}",
548
+ )
549
+
550
+ # Run ruff format on the new file
551
+ format_warning = _run_ruff_format(file_path)
552
+ if format_warning:
553
+ validation_warnings.append(format_warning)
554
+
555
+ # Read formatted code for preview
556
+ formatted_code = file_path.read_text(encoding="utf-8")
557
+
558
+ # Reload registry to pick up the new tool
559
+ try:
560
+ registry = get_registry()
561
+ registry.reload()
562
+ except Exception as e:
563
+ # Tool was written but registry reload failed - still a partial success
564
+ validation_warnings.append(f"Tool created but registry reload failed: {e}")
565
+
566
+ # Build full name for response
567
+ full_name = f"{final_namespace}.{final_name}" if final_namespace else final_name
568
+
569
+ return UniversalConstructorOutput(
570
+ action="create",
571
+ success=True,
572
+ create_result=UCCreateOutput(
573
+ success=True,
574
+ tool_name=full_name,
575
+ source_path=str(file_path),
576
+ preview=_generate_preview(formatted_code),
577
+ validation_warnings=validation_warnings,
578
+ ),
579
+ )
580
+
581
+
582
+ def _handle_update_action(
583
+ context: RunContext,
584
+ tool_name: Optional[str],
585
+ python_code: Optional[str],
586
+ description: Optional[str],
587
+ ) -> UniversalConstructorOutput:
588
+ """Handle the 'update' action - modify an existing UC tool.
589
+
590
+ Replaces an existing tool's code with new Python source code.
591
+ The new code must contain a valid TOOL_META dictionary.
592
+
593
+ Note: To update description or other metadata, include the changes
594
+ in the TOOL_META of the python_code. The description parameter is
595
+ reserved for future use but currently ignored.
596
+
597
+ Args:
598
+ context: The run context from pydantic-ai
599
+ tool_name: Name of the tool to update (required)
600
+ python_code: New Python source code (required)
601
+ description: Reserved for future use (currently ignored)
602
+
603
+ Returns:
604
+ UniversalConstructorOutput with update_result on success
605
+ """
606
+ from pathlib import Path
607
+
608
+ from code_puppy.plugins.universal_constructor.registry import get_registry
609
+ from code_puppy.plugins.universal_constructor.sandbox import (
610
+ _extract_tool_meta,
611
+ _validate_tool_meta,
612
+ validate_syntax,
613
+ )
614
+
615
+ if not tool_name:
616
+ return UniversalConstructorOutput(
617
+ action="update",
618
+ success=False,
619
+ error="tool_name is required for update action",
620
+ )
621
+
622
+ # python_code is required for updates
623
+ if not python_code:
624
+ return UniversalConstructorOutput(
625
+ action="update",
626
+ success=False,
627
+ error="python_code is required for update action",
628
+ )
629
+
630
+ registry = get_registry()
631
+ tool = registry.get_tool(tool_name)
632
+
633
+ if not tool:
634
+ return UniversalConstructorOutput(
635
+ action="update",
636
+ success=False,
637
+ error=f"Tool '{tool_name}' not found",
638
+ )
639
+
640
+ source_path = tool.source_path
641
+ source_path_obj = Path(source_path) if source_path else None
642
+ if not source_path_obj or not source_path_obj.exists():
643
+ return UniversalConstructorOutput(
644
+ action="update",
645
+ success=False,
646
+ error="Tool has no source path or file does not exist",
647
+ )
648
+
649
+ try:
650
+ # Validate new code syntax
651
+ syntax_result = validate_syntax(python_code)
652
+ if not syntax_result.valid:
653
+ error_msg = "; ".join(syntax_result.errors)
654
+ return UniversalConstructorOutput(
655
+ action="update",
656
+ success=False,
657
+ error=f"Syntax error in new code: {error_msg}",
658
+ )
659
+
660
+ # Validate TOOL_META exists in new code
661
+ new_meta = _extract_tool_meta(python_code)
662
+ if new_meta is None:
663
+ return UniversalConstructorOutput(
664
+ action="update",
665
+ success=False,
666
+ error="New code must contain a valid TOOL_META dictionary",
667
+ )
668
+
669
+ # Validate TOOL_META has required fields
670
+ meta_errors = _validate_tool_meta(new_meta)
671
+ if meta_errors:
672
+ return UniversalConstructorOutput(
673
+ action="update",
674
+ success=False,
675
+ error="Invalid TOOL_META: " + "; ".join(meta_errors),
676
+ )
677
+
678
+ # Write updated code
679
+ source_path_obj.write_text(python_code, encoding="utf-8")
680
+
681
+ # Run ruff format on the updated file
682
+ format_warning = _run_ruff_format(source_path_obj)
683
+ changes = ["Replaced source code"]
684
+ if format_warning:
685
+ changes.append(f"Format warning: {format_warning}")
686
+ else:
687
+ changes.append("Formatted with ruff")
688
+
689
+ # Read formatted code for preview
690
+ formatted_code = source_path_obj.read_text(encoding="utf-8")
691
+
692
+ # Reload registry to pick up changes
693
+ registry.reload()
694
+
695
+ return UniversalConstructorOutput(
696
+ action="update",
697
+ success=True,
698
+ update_result=UCUpdateOutput(
699
+ success=True,
700
+ tool_name=tool_name,
701
+ source_path=source_path,
702
+ preview=_generate_preview(formatted_code),
703
+ changes_applied=changes,
704
+ ),
705
+ )
706
+
707
+ except Exception as e:
708
+ return UniversalConstructorOutput(
709
+ action="update",
710
+ success=False,
711
+ error=f"Failed to update tool: {e}",
712
+ )
713
+
714
+
715
+ def _handle_info_action(
716
+ context: RunContext,
717
+ tool_name: Optional[str],
718
+ ) -> UniversalConstructorOutput:
719
+ """Handle the 'info' action - get detailed tool information.
720
+
721
+ Retrieves comprehensive information about a UC tool including its
722
+ metadata, source code, and function signature.
723
+
724
+ Args:
725
+ context: The run context from pydantic-ai
726
+ tool_name: Full name of the tool (including namespace)
727
+
728
+ Returns:
729
+ UniversalConstructorOutput with info_result containing tool details
730
+ """
731
+ from pathlib import Path
732
+
733
+ from code_puppy.plugins.universal_constructor.registry import get_registry
734
+
735
+ if not tool_name:
736
+ return UniversalConstructorOutput(
737
+ action="info",
738
+ success=False,
739
+ error="tool_name is required for info action",
740
+ )
741
+
742
+ registry = get_registry()
743
+ tool = registry.get_tool(tool_name)
744
+
745
+ if not tool:
746
+ return UniversalConstructorOutput(
747
+ action="info",
748
+ success=False,
749
+ error=f"Tool '{tool_name}' not found",
750
+ )
751
+
752
+ # Read source code from file
753
+ source_code = ""
754
+ source_path = tool.source_path
755
+ source_path_obj = Path(source_path) if source_path else None
756
+ if source_path_obj and source_path_obj.exists():
757
+ try:
758
+ source_code = source_path_obj.read_text(encoding="utf-8")
759
+ except Exception:
760
+ source_code = "[Could not read source]"
761
+ else:
762
+ source_code = "[Source file not found]"
763
+
764
+ return UniversalConstructorOutput(
765
+ action="info",
766
+ success=True,
767
+ info_result=UCInfoOutput(
768
+ success=True,
769
+ tool=tool,
770
+ source_code=source_code,
771
+ ),
772
+ )
773
+
774
+
775
+ def register_universal_constructor(agent):
776
+ """Register the universal_constructor tool with an agent.
777
+
778
+ Args:
779
+ agent: The pydantic-ai agent to register the tool with
780
+ """
781
+
782
+ @agent.tool
783
+ async def universal_constructor(
784
+ context: RunContext,
785
+ action: Literal["list", "call", "create", "update", "info"],
786
+ tool_name: Optional[str] = None,
787
+ tool_args: Optional[dict] = None,
788
+ python_code: Optional[str] = None,
789
+ description: Optional[str] = None,
790
+ ) -> UniversalConstructorOutput:
791
+ """Universal Constructor - Your gateway to unlimited capabilities.
792
+
793
+ **YOU CAN BUILD ANYTHING.** The Universal Constructor empowers you to create
794
+ any tool you can imagine using Python. If you can write Python code for it,
795
+ you can make it a permanent, reusable tool. This is your superpower.
796
+
797
+ **CAPABILITIES YOU CAN CREATE:**
798
+ - API integrations (REST, GraphQL, webhooks, any HTTP service)
799
+ - Database connectors (SQL, NoSQL, any data store)
800
+ - File processors (parsers, converters, analyzers for any format)
801
+ - Web scrapers and data extractors
802
+ - Mathematical and scientific computations
803
+ - Text processing and NLP utilities
804
+ - Image and media manipulation (with PIL, etc.)
805
+ - System utilities and automation scripts
806
+ - Network tools (DNS, HTTP, socket operations)
807
+ - Cryptographic functions
808
+ - Data transformation pipelines
809
+ - Custom business logic for any domain
810
+ - Integration with any Python library (requests, pandas, numpy, etc.)
811
+ - Scheduled tasks and background operations
812
+ - Custom validators and formatters
813
+ - Domain-specific calculators
814
+ - ANY Python functionality you need!
815
+
816
+ **WHEN TO USE THIS:**
817
+ - User asks for something your current tools can't do
818
+ - You need to call an external API or service
819
+ - You need custom data processing logic
820
+ - You want to automate a repetitive task
821
+ - You need domain-specific functionality
822
+ - ANY time you think "I wish I had a tool that..."
823
+
824
+ **PHILOSOPHY:** Don't say "I can't do that" - instead, BUILD a tool that can!
825
+ Tools you create persist across sessions, so build once, use forever.
826
+
827
+ Args:
828
+ action: The operation to perform:
829
+ - "list": List all available custom tools with their metadata
830
+ - "call": Execute a specific custom tool with provided arguments
831
+ - "create": Create a new tool from Python code
832
+ - "update": Modify an existing tool's code or metadata
833
+ - "info": Get detailed information about a specific tool
834
+ tool_name: Name of the tool (required for call/update/info actions).
835
+ Supports namespaced format like "namespace.tool_name" for organization.
836
+ tool_args: Dictionary of arguments to pass when calling a tool.
837
+ Only used with action="call".
838
+ python_code: Python source code defining the tool function.
839
+ Required for action="create" and action="update".
840
+ You have access to the FULL Python standard library plus any
841
+ installed packages (requests, etc.).
842
+ description: Human-readable description of what the tool does.
843
+ Used with action="create".
844
+
845
+ Returns:
846
+ UniversalConstructorOutput with action-specific results.
847
+
848
+ Examples:
849
+ # Create an API client tool
850
+ code = '''
851
+ import requests
852
+ TOOL_META = {"name": "weather", "description": "Get weather data"}
853
+ def weather(city: str) -> dict:
854
+ resp = requests.get(f"https://wttr.in/{city}?format=j1")
855
+ return resp.json()
856
+ '''
857
+ universal_constructor(ctx, action="create", python_code=code)
858
+
859
+ # Create a data processor
860
+ code = '''
861
+ import json
862
+ TOOL_META = {"name": "csv_to_json", "description": "Convert CSV to JSON"}
863
+ def csv_to_json(csv_text: str) -> list:
864
+ lines = csv_text.strip().split("\\n")
865
+ headers = lines[0].split(",")
866
+ return [{h: v for h, v in zip(headers, line.split(","))}
867
+ for line in lines[1:]]
868
+ '''
869
+ universal_constructor(ctx, action="create", python_code=code)
870
+
871
+ # Create a utility tool
872
+ code = '''
873
+ import hashlib
874
+ TOOL_META = {"name": "hasher", "description": "Hash strings"}
875
+ def hasher(text: str, algorithm: str = "sha256") -> str:
876
+ h = hashlib.new(algorithm)
877
+ h.update(text.encode())
878
+ return h.hexdigest()
879
+ '''
880
+ universal_constructor(ctx, action="create", python_code=code)
881
+
882
+ Note:
883
+ Tools are stored in ~/.code_puppy/plugins/universal_constructor/ and
884
+ persist forever. Organize with namespaces: "api.weather", "utils.hasher".
885
+ Code is auto-formatted with ruff. Check existing tools with action="list".
886
+ """
887
+ return await universal_constructor_impl(
888
+ context, action, tool_name, tool_args, python_code, description
889
+ )