Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 120 additions & 2 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
21 changes: 20 additions & 1 deletion cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
85 changes: 85 additions & 0 deletions cli/callbacks/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 2 additions & 1 deletion cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
33 changes: 33 additions & 0 deletions cli/commands/refresh.py
Original file line number Diff line number Diff line change
@@ -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}")
Loading
Loading