diff --git a/cli/README.md b/cli/README.md index 9b5d38c..5c67870 100644 --- a/cli/README.md +++ b/cli/README.md @@ -66,6 +66,7 @@ python -m cli | `/commands` | List special commands | | `/cost` | Show usage costs | | `/init` | Initialize .kader directory with KADER.md | +| `/refresh` | Refresh settings and reload callbacks | | `/update` | Check for updates and update Kader if newer version available | | `/exit` | Exit the CLI | | `!cmd` | Run terminal command | @@ -215,6 +216,97 @@ Usage: Usage: `/lint-test` or `/lint-test run full check` +## Callbacks System + +Kader supports callbacks — custom code that hooks into various stages of agent execution. Callbacks can modify tool arguments, log events, transform responses, and more. + +### Callback Locations + +Callbacks are loaded from two locations: + +- `./.kader/custom/callbacks/` — **Project-level callbacks** (auto-loaded, always enabled) +- `~/.kader/custom/callbacks/` — **User-level callbacks** (require configuration in settings.json) + +### Creating a Callback + +Create a Python file in the callbacks directory that defines a class extending `BaseCallback`, `ToolCallback`, or `LLMCallback`: + +```python +from kader.callbacks.tool_callbacks import ToolCallback + +class MyCallback(ToolCallback): + """Custom callback that modifies tool behavior.""" + + def __init__(self, enabled: bool = True): + super().__init__(tool_names=["execute_command"], enabled=enabled) + + def on_tool_before(self, context, tool_name: str, arguments: dict) -> dict: + """Called before tool execution.""" + # Modify arguments before execution + return arguments + + def on_tool_after(self, context, tool_name: str, arguments: dict, result) -> dict: + """Called after tool execution.""" + # Modify result after execution + return result +``` + +### Available Callback Base Classes + +| Class | Description | +|-------|-------------| +| `BaseCallback` | Abstract base class for all callbacks | +| `ToolCallback` | For tool execution events (before/after) | +| `LLMCallback` | For LLM invocation events (before/after) | + +### Callback Events + +| Event | Description | +|-------|-------------| +| `on_tool_before` | Called before a tool is executed | +| `on_tool_after` | Called after a tool is executed | +| `on_agent_start` | Called when agent starts execution | +| `on_agent_end` | Called when agent finishes execution | +| `on_llm_start` | Called before LLM is invoked | +| `on_llm_end` | Called after LLM response is received | +| `on_error` | Called when an error occurs | + +### User-Level Callbacks Configuration + +User-level callbacks must be explicitly enabled in `~/.kader/settings.json`: + +```json +{ + "main-agent-provider": "ollama", + "main-agent-model": "glm-5:cloud", + "callbacks": [ + {"name": "my_callback", "enabled": "true"}, + {"name": "other_callback", "enabled": "false"} + ] +} +``` + +- `name`: The filename (without `.py` extension) containing the callback class +- `enabled`: `"true"` to enable, `"false"` to disable + +### Project-Level Callbacks + +Project-level callbacks in `./.kader/custom/callbacks/` are automatically discovered and loaded. They don't require any configuration — just drop the file in the directory. + +### Refresh Command + +Use `/refresh` to reload settings and callbacks without restarting the CLI: + +- Reloads `settings.json` from disk +- Re-discovers project-level callbacks +- Re-loads user-level callbacks based on updated settings +- Recreates the workflow with new configuration + +This is useful when: +- Adding new callbacks to your project +- Enabling/disabling callbacks in settings +- Changing model or provider settings + ## Model Selection Interface The `/models` command uses a two-step interactive flow: @@ -257,11 +349,12 @@ Kader stores user preferences in `~/.kader/settings.json`. This file is created "sub-agent-provider": "ollama", "main-agent-model": "glm-5:cloud", "sub-agent-model": "glm-5:cloud", - "auto-update": false + "auto-update": false, + "callbacks": [] } ``` -Settings are updated automatically when you switch models via `/models`. You can also edit the file directly. +Settings are updated automatically when you switch models via `/models`. You can also edit the file directly. The `callbacks` directory at `~/.kader/custom/callbacks` is also created automatically. ### Available Settings @@ -272,6 +365,31 @@ Settings are updated automatically when you switch models via `/models`. You can | `main-agent-model` | Model name for the planner agent | `glm-5:cloud` | | `sub-agent-model` | Model name for executor sub-agents | `glm-5:cloud` | | `auto-update` | Automatically update Kader on startup | `false` | +| `callbacks` | List of user-level callbacks to enable | `[]` | + +### Callbacks Configuration + +The `callbacks` field is an array of callback objects: + +```json +{ + "callbacks": [ + {"name": "my_callback", "enabled": "true"}, + {"name": "other_callback", "enabled": "false"} + ] +} +``` + +- `name`: The filename (without `.py` extension) in `~/.kader/custom/callbacks/` +- `enabled`: `"true"` to enable, `"false"` to disable + +When Kader starts, it automatically: +1. Creates `~/.kader/custom/callbacks/` directory if it doesn't exist +2. Discovers any callback files in that directory +3. Adds them to settings with `enabled: "false"` by default +4. Loads enabled callbacks from settings + +Use `/refresh` to reload callbacks after making changes. ### Auto-Update diff --git a/cli/app.py b/cli/app.py index ede7d41..3c831e6 100644 --- a/cli/app.py +++ b/cli/app.py @@ -38,7 +38,8 @@ from kader.utils.todo_metadata import TodoMetadataHandler from kader.workflows import PlannerExecutorWorkflow -from .commands import InitializeCommand, UpdateCommand +from .callbacks import load_callbacks_from_settings +from .commands import InitializeCommand, RefreshCommand, UpdateCommand from .llm_factory import LLMProviderFactory from .settings import load_settings, save_settings from .utils import COMMAND_NAMES, HELP_TEXT @@ -153,6 +154,9 @@ def _create_workflow(self, model_name: str) -> PlannerExecutorWorkflow: executor_model = self._settings.get_sub_model_string() executor_provider = LLMProviderFactory.create_provider(executor_model) + # Load callbacks from settings + callbacks = load_callbacks_from_settings(self._settings) + workflow = PlannerExecutorWorkflow( name="kader_cli", provider=provider, @@ -165,6 +169,7 @@ def _create_workflow(self, model_name: str) -> PlannerExecutorWorkflow: executor_names=["executor"], executor_model_name=executor_model, executor_provider=executor_provider, + callbacks=callbacks, ) if not self._current_session_id: @@ -500,6 +505,10 @@ async def _handle_command(self, command: str) -> None: init_cmd = InitializeCommand(self) await init_cmd.execute() + elif cmd == "/refresh": + refresh_cmd = RefreshCommand(self) + await refresh_cmd.execute() + elif cmd == "/update": update_cmd = UpdateCommand(self) await update_cmd.execute() @@ -1268,6 +1277,16 @@ def _handle_cost(self) -> None: except Exception as e: self.console.print(f" [kader.red]✗[/kader.red] Error getting costs: {e}") + # ── Refresh settings ─────────────────────────────────────────────── + + def _refresh_settings(self) -> None: + """Reload settings and callbacks, recreate workflow.""" + from cli.settings import load_settings + + self._settings = load_settings() + self._current_model = self._settings.get_main_model_string() + self._workflow = self._create_workflow(self._current_model) + # ── Update check ───────────────────────────────────────────────── def _check_for_updates(self) -> None: diff --git a/cli/callbacks/__init__.py b/cli/callbacks/__init__.py new file mode 100644 index 0000000..b54dd3a --- /dev/null +++ b/cli/callbacks/__init__.py @@ -0,0 +1,85 @@ +"""CLI callbacks integration for Kader CLI. + +Provides functionality to load custom callbacks from user and project directories. +""" + +from pathlib import Path + +from loguru import logger + +from cli.settings.settings import KaderSettings +from kader.callbacks.base import BaseCallback +from kader.callbacks.loader import CallbackLoader + + +def load_callbacks_from_settings(settings: KaderSettings) -> list[BaseCallback]: + """ + Load callbacks based on settings configuration. + + Loading order: + 1. Project-level callbacks from ./.kader/custom/callbacks (always enabled) + 2. User-level callbacks from settings.json (controlled by 'enabled' field) + + Args: + settings: KaderSettings containing callback configuration + + Returns: + List of instantiated callback objects + """ + callbacks: list[BaseCallback] = [] + + project_callbacks_dir = Path.cwd() / ".kader" / "custom" / "callbacks" + user_callbacks_dir = Path.home() / ".kader" / "custom" / "callbacks" + + project_loader = CallbackLoader(callbacks_dirs=[project_callbacks_dir]) + user_loader = CallbackLoader(callbacks_dirs=[user_callbacks_dir]) + + project_callback_classes = project_loader.list_callbacks() + for callback_class in project_callback_classes: + try: + instance = callback_class() + if isinstance(instance, BaseCallback): + callbacks.append(instance) + logger.info(f"Loaded project callback: {callback_class.__name__}") + except Exception as e: + logger.warning( + f"Failed to instantiate project callback {callback_class.__name__}: {e}" + ) + + callback_configs = settings.callbacks or [] + for config in callback_configs: + if not isinstance(config, dict): + continue + + name = config.get("name") + enabled_str = config.get("enabled", "true") + enabled = ( + enabled_str.lower() == "true" + if isinstance(enabled_str, str) + else bool(enabled_str) + ) + + if not name: + continue + + if not enabled: + logger.debug(f"Skipping disabled callback: {name}") + continue + + callback_class = user_loader.load_callback(name) + if callback_class is None: + logger.debug(f"User callback not found: {name}") + continue + + try: + instance = callback_class() + if isinstance(instance, BaseCallback): + callbacks.append(instance) + logger.info(f"Loaded user callback: {name}") + except Exception as e: + logger.warning(f"Failed to instantiate user callback {name}: {e}") + + return callbacks + + +__all__ = ["load_callbacks_from_settings"] diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py index d7da154..63dd4c6 100644 --- a/cli/commands/__init__.py +++ b/cli/commands/__init__.py @@ -2,6 +2,7 @@ from .base import BaseCommand from .initialize import InitializeCommand +from .refresh import RefreshCommand from .update import UpdateCommand -__all__ = ["BaseCommand", "InitializeCommand", "UpdateCommand"] +__all__ = ["BaseCommand", "InitializeCommand", "RefreshCommand", "UpdateCommand"] diff --git a/cli/commands/refresh.py b/cli/commands/refresh.py new file mode 100644 index 0000000..d78a4ed --- /dev/null +++ b/cli/commands/refresh.py @@ -0,0 +1,33 @@ +"""Refresh command for Kader CLI. + +Refreshes settings and reloads callbacks without restarting the CLI. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from loguru import logger + +from cli.commands.base import BaseCommand + +if TYPE_CHECKING: + pass + + +class RefreshCommand(BaseCommand): + """Handles the /refresh command for Kader CLI. + + Reloads settings and callbacks, recreates the workflow with new configuration. + """ + + async def execute(self) -> None: + """Execute the refresh command.""" + try: + self.app._refresh_settings() + self.app.console.print( + r" [kader.green]\[+][/kader.green] Settings and callbacks refreshed successfully" + ) + except Exception as e: + logger.error(f"Failed to refresh settings: {e}") + self.app.console.print(rf" [kader.red]\[-] Failed to refresh: {e}") diff --git a/cli/settings/settings.py b/cli/settings/settings.py index df61d70..6427e51 100644 --- a/cli/settings/settings.py +++ b/cli/settings/settings.py @@ -10,7 +10,7 @@ import json from dataclasses import dataclass, field from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar from loguru import logger @@ -40,6 +40,7 @@ "main-agent-model": "main_agent_model", "sub-agent-model": "sub_agent_model", "auto-update": "auto_update", + "callbacks": "callbacks", } _FIELD_KEY_MAP: dict[str, str] = {v: k for k, v in _JSON_KEY_MAP.items()} @@ -55,6 +56,8 @@ class KaderSettings: main_agent_model: Model identifier for the planner agent. sub_agent_model: Model identifier for executor sub-agents. auto_update: Whether to automatically update Kader on startup. + callbacks: List of user-level callbacks to enable. + Format: [{"name": "module.ClassName", "enabled": "true/false"}] """ VALID_PROVIDERS: ClassVar[set[str]] = VALID_PROVIDERS @@ -64,6 +67,7 @@ class KaderSettings: main_agent_model: str = field(default=_DEFAULT_MAIN_MODEL) sub_agent_model: str = field(default=_DEFAULT_SUB_MODEL) auto_update: bool = field(default=False) + callbacks: list[dict[str, Any]] = field(default_factory=list) def __post_init__(self) -> None: """Validate provider values after initialisation.""" @@ -82,12 +86,12 @@ def to_dict(self) -> dict[str, str]: return {_FIELD_KEY_MAP[f]: getattr(self, f) for f in _FIELD_KEY_MAP} @classmethod - def from_dict(cls, data: dict[str, str]) -> KaderSettings: + def from_dict(cls, data: dict[str, Any]) -> KaderSettings: """Create a ``KaderSettings`` from a dict with hyphenated JSON keys. Unknown keys are silently ignored; missing keys use defaults. """ - kwargs: dict[str, str | bool] = {} + kwargs: dict[str, Any] = {} for json_key, field_name in _JSON_KEY_MAP.items(): if json_key in data: if field_name == "auto_update": @@ -98,6 +102,10 @@ def from_dict(cls, data: dict[str, str]) -> KaderSettings: kwargs[field_name] = value.lower() == "true" else: kwargs[field_name] = bool(value) + elif field_name == "callbacks": + value = data[json_key] + if isinstance(value, list): + kwargs[field_name] = value else: kwargs[field_name] = data[json_key] return cls(**kwargs) @@ -167,6 +175,8 @@ def migrate_settings(path: Path | None = None) -> KaderSettings: Reads the existing settings file, adds any missing keys with their default values, and saves the updated file. Existing keys are preserved. + Also auto-discovers user-level callbacks from ~/.kader/custom/callbacks + and adds them to settings if not already present. Returns the loaded (potentially migrated) settings. """ @@ -191,6 +201,11 @@ def migrate_settings(path: Path | None = None) -> KaderSettings: migrated = True logger.info(f"Added missing setting '{key}' with default value") + data = _migrate_user_callbacks(data) + if data.get("_callbacks_migrated"): + migrated = True + del data["_callbacks_migrated"] + if migrated: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -200,3 +215,52 @@ def migrate_settings(path: Path | None = None) -> KaderSettings: logger.info(f"Migrated settings file at {path}") return KaderSettings.from_dict(data) + + +def _migrate_user_callbacks(data: dict[str, Any]) -> dict[str, Any]: + """Auto-discover user-level callbacks and add them to settings. + + Checks ~/.kader/custom/callbacks for callback files and adds any + that are not already in the settings callbacks list. + Creates the directory if it doesn't exist. + + Args: + data: Existing settings data dict + + Returns: + Updated data dict with discovered callbacks + """ + user_callbacks_dir = Path.home() / ".kader" / "custom" / "callbacks" + + if not user_callbacks_dir.exists(): + try: + user_callbacks_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Created user callbacks directory: {user_callbacks_dir}") + except Exception as e: + logger.warning(f"Failed to create user callbacks directory: {e}") + return data + return data + + existing_callbacks = data.get("callbacks", []) + existing_names = { + cb.get("name") for cb in existing_callbacks if isinstance(cb, dict) + } + + discovered: list[dict[str, str]] = [] + for callback_file in user_callbacks_dir.iterdir(): + if not callback_file.is_file() or callback_file.suffix != ".py": + continue + if callback_file.stem.startswith("_"): + continue + + callback_name = callback_file.stem + if callback_name not in existing_names: + discovered.append({"name": callback_name, "enabled": "false"}) + logger.info(f"Discovered user callback: {callback_name}") + + if discovered: + data["callbacks"] = existing_callbacks + discovered + data["_callbacks_migrated"] = True + logger.info(f"Added {len(discovered)} user callbacks to settings") + + return data diff --git a/cli/utils.py b/cli/utils.py index 86d922b..075beb4 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -54,6 +54,7 @@ def get_special_commands() -> list[CLICommand]: CLICommand(name="/commands", description="List special commands"), CLICommand(name="/cost", description="Show usage costs"), CLICommand(name="/init", description="Initialize .kader directory with KADER.md"), + CLICommand(name="/refresh", description="Refresh settings and reload callbacks"), CLICommand( name="/update", description="Check for updates and update Kader if newer version available", @@ -109,6 +110,8 @@ def get_commands_text() -> str: | `/commands` | List special commands | | `/cost` | Show usage costs | | `/init` | Initialize .kader directory with KADER.md | +| `refresh` | Refresh settings of Kader CLI | +| `update` | Check for updates and update Kader | | `/exit` | Exit the CLI | | `!cmd` | Run terminal command | diff --git a/docs/cli/index.md b/docs/cli/index.md index a829728..56d2d56 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -44,6 +44,7 @@ uv run python -m cli | `/commands` | List special commands | | `/cost` | Show usage costs | | `/init` | Initialize .kader directory with KADER.md | +| `/refresh` | Refresh settings and reload callbacks | | `/update` | Check for updates and update Kader if newer version available | | `/exit` | Exit the CLI | | `!cmd` | Run terminal command | @@ -188,6 +189,77 @@ Usage: - `/lint-test/lint` - Run linting only - `/lint-test/test` - Run tests only +## Callbacks System + +Kader supports callbacks — custom code that hooks into various stages of agent execution. Callbacks can modify tool arguments, log events, transform responses, and more. + +### Callback Locations + +Callbacks are loaded from two locations: + +- `./.kader/custom/callbacks/` — **Project-level callbacks** (auto-loaded, always enabled) +- `~/.kader/custom/callbacks/` — **User-level callbacks** (require configuration in settings.json) + +### Creating a Callback + +Create a Python file in the callbacks directory that defines a class extending `BaseCallback`, `ToolCallback`, or `LLMCallback`: + +```python +from kader.callbacks.tool_callbacks import ToolCallback + +class MyCallback(ToolCallback): + """Custom callback that modifies tool behavior.""" + + def __init__(self, enabled: bool = True): + super().__init__(tool_names=["execute_command"], enabled=enabled) + + def on_tool_before(self, context, tool_name: str, arguments: dict) -> dict: + """Called before tool execution.""" + # Modify arguments before execution + return arguments +``` + +### Available Callback Base Classes + +| Class | Description | +|-------|-------------| +| `BaseCallback` | Abstract base class for all callbacks | +| `ToolCallback` | For tool execution events (before/after) | +| `LLMCallback` | For LLM invocation events (before/after) | + +### Callback Events + +| Event | Description | +|-------|-------------| +| `on_tool_before` | Called before a tool is executed | +| `on_tool_after` | Called after a tool is executed | +| `on_agent_start` | Called when agent starts execution | +| `on_agent_end` | Called when agent finishes execution | +| `on_llm_start` | Called before LLM is invoked | +| `on_llm_end` | Called after LLM response is received | +| `on_error` | Called when an error occurs | + +### User-Level Callbacks Configuration + +User-level callbacks must be explicitly enabled in `~/.kader/settings.json`: + +```json +{ + "callbacks": [ + {"name": "my_callback", "enabled": "true"}, + {"name": "other_callback", "enabled": "false"} + ] +} +``` + +### Project-Level Callbacks + +Project-level callbacks in `./.kader/custom/callbacks/` are automatically discovered and loaded without any configuration. + +### Refresh Command + +Use `/refresh` to reload settings and callbacks without restarting the CLI. + ## Model Selection The `/models` command uses a two-step interactive flow: @@ -211,11 +283,12 @@ User preferences are stored in `~/.kader/settings.json`, auto-created on first r "sub-agent-provider": "ollama", "main-agent-model": "glm-5:cloud", "sub-agent-model": "glm-5:cloud", - "auto-update": false + "auto-update": false, + "callbacks": [] } ``` -Settings update automatically when switching models via `/models`. +Settings update automatically when switching models via `/models`. The `~/.kader/custom/callbacks` directory is also created automatically. ### Available Settings @@ -226,6 +299,7 @@ Settings update automatically when switching models via `/models`. | `main-agent-model` | Model name for the planner agent | `glm-5:cloud` | | `sub-agent-model` | Model name for executor sub-agents | `glm-5:cloud` | | `auto-update` | Automatically update Kader on startup | `false` | +| `callbacks` | List of user-level callbacks to enable | `[]` | ### Auto-Update diff --git a/kader/callbacks/__init__.py b/kader/callbacks/__init__.py index 1d41c20..b8dcb22 100644 --- a/kader/callbacks/__init__.py +++ b/kader/callbacks/__init__.py @@ -25,6 +25,7 @@ def on_tool_before(self, context, tool_name, arguments): LLMCallback, LoggingLLMCallback, ) +from .loader import CallbackLoader from .tool_callbacks import ( LoggingToolCallback, ToolCallback, @@ -34,6 +35,7 @@ def on_tool_before(self, context, tool_name, arguments): "BaseCallback", "CallbackContext", "CallbackEvent", + "CallbackLoader", "ToolCallback", "LoggingToolCallback", "LLMCallback", diff --git a/kader/callbacks/loader.py b/kader/callbacks/loader.py new file mode 100644 index 0000000..3e038b0 --- /dev/null +++ b/kader/callbacks/loader.py @@ -0,0 +1,178 @@ +"""Callback loader for Kader framework. + +Provides functionality to discover and load custom callbacks from directories, +similar to how skills and commands are loaded. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import inspect +import sys +from pathlib import Path + +from loguru import logger + +from kader.callbacks.base import BaseCallback + + +class CallbackLoader: + """Loads callbacks from callback directories.""" + + def __init__( + self, + callbacks_dirs: list[Path] | None = None, + priority_dir: Path | None = None, + ) -> None: + """ + Initialize the callback loader. + + Args: + callbacks_dirs: List of directories to load callbacks from. + If None, defaults to ~/.kader/custom/callbacks + and ./.kader/custom/callbacks + priority_dir: Optional directory to check first (higher priority). + If provided, this directory is checked before others. + """ + self._priority_dir = priority_dir + + if callbacks_dirs is None: + home_callbacks = Path.home() / ".kader" / "custom" / "callbacks" + cwd_callbacks = Path.cwd() / ".kader" / "custom" / "callbacks" + callbacks_dirs = [home_callbacks, cwd_callbacks] + + if priority_dir is not None: + callbacks_dirs = [priority_dir] + callbacks_dirs + + self.callbacks_dirs = callbacks_dirs + + def _find_callback_file(self, name: str) -> tuple[Path, Path] | None: + """ + Find the callback file in the callbacks directories. + + Args: + name: Name of the callback to find + + Returns: + Tuple of (callbacks_dir, callback_file) if found, None otherwise + """ + for callbacks_dir in self.callbacks_dirs: + if not callbacks_dir.exists(): + continue + callback_file = callbacks_dir / f"{name}.py" + if callback_file.exists(): + return (callbacks_dir, callback_file) + return None + + def _get_callback_class( + self, module_name: str, module_path: Path + ) -> type[BaseCallback] | None: + """ + Find and return callback classes from a module. + + Args: + module_name: Name of the module to search + module_path: Path to the module file + + Returns: + The first BaseCallback subclass found, or None + """ + try: + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + return None + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + + spec.loader.exec_module(module) + + for _, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, BaseCallback) + and obj is not BaseCallback + and not obj.__name__.startswith("_") + ): + return obj + + return None + except Exception as e: + logger.warning(f"Failed to load callback class from {module_path}: {e}") + return None + + def load_callback(self, name: str) -> type[BaseCallback] | None: + """ + Load a callback by name. + + Args: + name: Name of the callback to load (can include module path, + e.g., "my_callback.MyCallback" or just "MyCallback") + + Returns: + Callback class if found, None otherwise + """ + if "." in name: + parts = name.rsplit(".", 1) + module_name = parts[0] + class_name = parts[1] + + result = self._find_callback_file(module_name) + if result is None: + return None + + _, callback_file = result + + callback_class = self._get_callback_class(module_name, callback_file) + if callback_class is None: + return None + + if callback_class.__name__ != class_name: + logger.warning( + f"Callback class name mismatch: expected {class_name}, " + f"found {callback_class.__name__}" + ) + return None + + return callback_class + + result = self._find_callback_file(name) + if result is None: + return None + + _, callback_file = result + + module_name = name + return self._get_callback_class(module_name, callback_file) + + def list_callbacks(self) -> list[type[BaseCallback]]: + """ + List all available callbacks from all directories. + + Returns: + List of all available callback classes + """ + callbacks: list[type[BaseCallback]] = [] + seen_names: set[str] = set() + + for callbacks_dir in self.callbacks_dirs: + if not callbacks_dir.exists(): + continue + + for callback_file in sorted(callbacks_dir.iterdir()): + if not callback_file.is_file() or not callback_file.suffix == ".py": + continue + + if callback_file.stem.startswith("_"): + continue + + name = callback_file.stem + if name in seen_names: + continue + + callback_class = self._get_callback_class(name, callback_file) + if callback_class: + callbacks.append(callback_class) + seen_names.add(name) + + return callbacks diff --git a/pyproject.toml b/pyproject.toml index ab4adc3..d33c13c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kader" -version = "2.9.2" +version = "2.10.0" description = "kader coding agent" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_settings.py b/tests/test_settings.py index 39106ff..f8c9f81 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -162,5 +162,6 @@ def test_saved_json_format(self, tmp_path: Path) -> None: "main-agent-model", "sub-agent-model", "auto-update", + "callbacks", } assert set(data.keys()) == expected_keys diff --git a/uv.lock b/uv.lock index 9e7e49c..3587855 100644 --- a/uv.lock +++ b/uv.lock @@ -629,7 +629,7 @@ wheels = [ [[package]] name = "kader" -version = "2.9.2" +version = "2.10.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },