kader 0.1.5__py3-none-any.whl → 1.0.0__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.
kader/tools/agent.py ADDED
@@ -0,0 +1,452 @@
1
+ """
2
+ Agent Tool - Use a ReActAgent as a callable tool.
3
+
4
+ Allows spawning sub-agents to execute specific tasks with isolated memory contexts.
5
+ """
6
+
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Optional, Tuple
10
+
11
+ from kader.memory import SlidingWindowConversationManager
12
+ from kader.memory.types import aread_text, save_json
13
+ from kader.prompts import ExecutorAgentPrompt
14
+ from kader.providers.base import BaseLLMProvider, Message
15
+ from kader.utils import Checkpointer, ContextAggregator
16
+
17
+ from .base import BaseTool, ParameterSchema, ToolCategory
18
+
19
+
20
+ class PersistentSlidingWindowConversationManager(SlidingWindowConversationManager):
21
+ """
22
+ SlidingWindowConversationManager with JSON persistence.
23
+
24
+ Saves the entire message history (dict format) to a JSON file
25
+ after every add_message(s) call.
26
+ """
27
+
28
+ def __init__(self, file_path: Path, window_size: int = 20) -> None:
29
+ """
30
+ Initialize with a file path for persistence.
31
+ """
32
+ super().__init__(window_size=window_size)
33
+ self.file_path = file_path
34
+
35
+ def _save(self) -> None:
36
+ """Save entire history to JSON."""
37
+ try:
38
+ # We want to save plain dicts
39
+ messages_dicts = [msg.message for msg in self._messages]
40
+ data = {"messages": messages_dicts}
41
+ # Ensure parent temp-directory exists is done by caller usually,
42
+ # but best effort here:
43
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
44
+ save_json(self.file_path, data)
45
+ except Exception:
46
+ # Best effort save
47
+ pass
48
+
49
+ def add_message(self, message: Any) -> Any:
50
+ # Call super
51
+ result = super().add_message(message)
52
+ # Save
53
+ self._save()
54
+ return result
55
+
56
+ def add_messages(self, messages: list[Any]) -> list[Any]:
57
+ # Call super
58
+ result = super().add_messages(messages)
59
+ # Save
60
+ self._save()
61
+ return result
62
+
63
+
64
+ class AgentTool(BaseTool[str]):
65
+ """
66
+ Tool that spawns a ReActAgent to execute a specific task.
67
+
68
+ Creates an agent with its own memory context and default tools
69
+ (filesystem, web, command executor) to complete the given task.
70
+
71
+ When `interrupt_before_tool=True`, the agent will pause before each tool
72
+ execution and use the `tool_confirmation_callback` to get user confirmation.
73
+
74
+ Example:
75
+ # Autonomous execution (no interrupts)
76
+ tool = AgentTool(name="research_agent", interrupt_before_tool=False)
77
+ result = tool.execute(task="Find the current stock price of AAPL")
78
+
79
+ # Interactive execution with tool confirmation
80
+ def my_callback(tool_info: str) -> Tuple[bool, Optional[str]]:
81
+ user_input = input(f"Execute {tool_info}? [y/n]: ")
82
+ return (user_input.lower() == 'y', None)
83
+
84
+ tool = AgentTool(
85
+ name="research_agent",
86
+ interrupt_before_tool=True,
87
+ tool_confirmation_callback=my_callback
88
+ )
89
+ result = tool.execute(task="Find info about topic X")
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ name: str,
95
+ description: str = "Execute a specific task using an AI agent",
96
+ provider: Optional[BaseLLMProvider] = None,
97
+ model_name: str = "qwen3-coder:480b-cloud",
98
+ interrupt_before_tool: bool = True,
99
+ tool_confirmation_callback: Optional[
100
+ Callable[..., Tuple[bool, Optional[str]]]
101
+ ] = None,
102
+ direct_execution_callback: Optional[Callable[..., None]] = None,
103
+ tool_execution_result_callback: Optional[Callable[..., None]] = None,
104
+ ) -> None:
105
+ """
106
+ Initialize the AgentTool.
107
+
108
+ Args:
109
+ name: Name of the agent tool (used as identifier).
110
+ description: Description of what this agent does.
111
+ provider: Optional LLM provider (uses OllamaProvider by default).
112
+ model_name: Model to use for the agent.
113
+ interrupt_before_tool: If True, pause before tool execution for user
114
+ confirmation. The task will only complete when the agent returns
115
+ its final response.
116
+ tool_confirmation_callback: Callback function for tool confirmation.
117
+ Should return (should_execute: bool, additional_context: Optional[str]).
118
+ If not provided and interrupt_before_tool=True, uses stdin prompts.
119
+ """
120
+ super().__init__(
121
+ name=name,
122
+ description=description,
123
+ parameters=[
124
+ ParameterSchema(
125
+ name="task",
126
+ type="string",
127
+ description="The specific task for the agent to execute",
128
+ required=True,
129
+ ),
130
+ ParameterSchema(
131
+ name="context",
132
+ type="string",
133
+ description="Context to provide to the agent before executing the task",
134
+ required=True,
135
+ ),
136
+ ],
137
+ category=ToolCategory.UTILITY,
138
+ )
139
+ self._provider = provider
140
+ self._model_name = model_name
141
+ self._interrupt_before_tool = interrupt_before_tool
142
+ self._tool_confirmation_callback = tool_confirmation_callback
143
+ self._direct_execution_callback = direct_execution_callback
144
+ self._tool_execution_result_callback = tool_execution_result_callback
145
+
146
+ def _load_aggregated_context(self, main_session_id: str) -> str | None:
147
+ """
148
+ Load the aggregated checkpoint from executors directory if it exists.
149
+
150
+ Args:
151
+ main_session_id: The main session ID
152
+
153
+ Returns:
154
+ Content of the aggregated checkpoint, or None if not found
155
+ """
156
+ if main_session_id == "standalone":
157
+ return None
158
+
159
+ home = Path.home()
160
+ aggregated_path = (
161
+ home
162
+ / ".kader"
163
+ / "memory"
164
+ / "sessions"
165
+ / main_session_id
166
+ / "executors"
167
+ / "checkpoint.md"
168
+ )
169
+
170
+ if aggregated_path.exists():
171
+ try:
172
+ return aggregated_path.read_text(encoding="utf-8")
173
+ except Exception:
174
+ return None
175
+ return None
176
+
177
+ async def _aload_aggregated_context(self, main_session_id: str) -> str | None:
178
+ """
179
+ Asynchronously load the aggregated checkpoint from executors directory.
180
+
181
+ Args:
182
+ main_session_id: The main session ID
183
+
184
+ Returns:
185
+ Content of the aggregated checkpoint, or None if not found
186
+ """
187
+ if main_session_id == "standalone":
188
+ return None
189
+
190
+ home = Path.home()
191
+ aggregated_path = (
192
+ home
193
+ / ".kader"
194
+ / "memory"
195
+ / "sessions"
196
+ / main_session_id
197
+ / "executors"
198
+ / "checkpoint.md"
199
+ )
200
+
201
+ if aggregated_path.exists():
202
+ try:
203
+ return await aread_text(aggregated_path)
204
+ except Exception:
205
+ return None
206
+ return None
207
+
208
+ def execute(self, task: str, context: str) -> str:
209
+ """
210
+ Execute a task using a ReActAgent with isolated memory.
211
+
212
+ When interrupt_before_tool is True, the agent will pause before each tool
213
+ execution for user confirmation. The task only ends when the agent returns
214
+ its final response.
215
+
216
+ Args:
217
+ task: The task to execute.
218
+ context: Context to add to memory before the task.
219
+
220
+ Returns:
221
+ A summary of what the agent accomplished.
222
+ """
223
+ # Import here to avoid circular imports
224
+ from kader.agent.agents import ReActAgent
225
+
226
+ # Create a fresh memory manager for isolated context
227
+ # Persistence: ~/.kader/memory/sessions/<main-session-id>/executors/<agent-name>-<id>.json
228
+ execution_id = str(uuid.uuid4())
229
+ # Use propagated session ID or 'standalone' if not set
230
+ main_session_id = self._session_id if self._session_id else "standalone"
231
+
232
+ home = Path.home()
233
+ memory_dir = (
234
+ home
235
+ / ".kader"
236
+ / "memory"
237
+ / "sessions"
238
+ / main_session_id
239
+ / "executors"
240
+ / f"{self.name}-{execution_id}"
241
+ )
242
+ memory_file = memory_dir / "conversation.json"
243
+
244
+ memory = PersistentSlidingWindowConversationManager(
245
+ file_path=memory_file, window_size=20
246
+ )
247
+
248
+ # Load aggregated context from previous executors
249
+ aggregated_context = self._load_aggregated_context(main_session_id)
250
+ if aggregated_context:
251
+ full_context = f"## Previous Executor Context\n{aggregated_context}\n\n## Current Task Context\n{context}"
252
+ else:
253
+ full_context = context
254
+
255
+ # Add context to memory as user message
256
+ memory.add_message(Message.user(full_context))
257
+
258
+ # Get default tools (filesystem, web, command executor) - use cached version
259
+ from kader.tools import get_cached_default_registry
260
+
261
+ tools = get_cached_default_registry()
262
+
263
+ # Create ExecutorAgentPrompt with tool descriptions
264
+ system_prompt = ExecutorAgentPrompt(tools=tools.tools)
265
+
266
+ # Create the ReActAgent with separate memory and executor prompt
267
+ agent = ReActAgent(
268
+ name=f"{self.name}_worker",
269
+ tools=tools,
270
+ system_prompt=system_prompt,
271
+ provider=self._provider,
272
+ memory=memory,
273
+ model_name=self._model_name,
274
+ interrupt_before_tool=self._interrupt_before_tool,
275
+ tool_confirmation_callback=self._tool_confirmation_callback,
276
+ direct_execution_callback=self._direct_execution_callback,
277
+ tool_execution_result_callback=self._tool_execution_result_callback,
278
+ )
279
+
280
+ try:
281
+ # Invoke the agent with the task
282
+ # The agent will handle tool interruptions internally
283
+ response = agent.invoke(task)
284
+
285
+ # Generate checkpoint and aggregate it
286
+ try:
287
+ checkpointer = Checkpointer()
288
+ checkpoint_path = checkpointer.generate_checkpoint(str(memory_file))
289
+ checkpoint_content = Path(checkpoint_path).read_text(encoding="utf-8")
290
+
291
+ # Aggregate the checkpoint into the main executors checkpoint
292
+ if main_session_id != "standalone":
293
+ aggregator = ContextAggregator(session_id=main_session_id)
294
+ # Use relative path from executors directory
295
+ relative_path = f"{self.name}-{execution_id}/checkpoint.md"
296
+ aggregator.aggregate(relative_path, subagent_name=self.name)
297
+
298
+ # Append the agent's response to the checkpoint content if it exists
299
+ response_content = None
300
+ if hasattr(response, "content"):
301
+ response_content = str(response.content)
302
+ elif isinstance(response, dict):
303
+ response_content = str(response.get("content", str(response)))
304
+ else:
305
+ response_content = str(response)
306
+
307
+ if response_content and response_content != "None":
308
+ checkpoint_content += f"\n\nResponse:\n{response_content}"
309
+
310
+ return checkpoint_content
311
+ except Exception:
312
+ # Fallback to raw response if checkpointing fails
313
+ if hasattr(response, "content"):
314
+ return str(response.content)
315
+ elif isinstance(response, dict):
316
+ return str(response.get("content", str(response)))
317
+ else:
318
+ return str(response)
319
+
320
+ except Exception as e:
321
+ return f"Agent execution failed: {str(e)}"
322
+
323
+ async def aexecute(self, task: str, context: str) -> str:
324
+ """
325
+ Asynchronously execute a task using a ReActAgent.
326
+
327
+ When interrupt_before_tool is True, the agent will pause before each tool
328
+ execution for user confirmation. The task only ends when the agent returns
329
+ its final response.
330
+
331
+ Args:
332
+ task: The task to execute.
333
+ context: Context to add to memory before the task.
334
+
335
+ Returns:
336
+ A summary of what the agent accomplished.
337
+ """
338
+ # Import here to avoid circular imports
339
+ from kader.agent.agents import ReActAgent
340
+
341
+ # Create a fresh memory manager for isolated context
342
+ # Persistence: ~/.kader/memory/sessions/<main-session-id>/executors/<agent-name>-<id>.json
343
+ execution_id = str(uuid.uuid4())
344
+ # Use propagated session ID or 'standalone' if not set
345
+ main_session_id = self._session_id if self._session_id else "standalone"
346
+
347
+ home = Path.home()
348
+ memory_dir = (
349
+ home
350
+ / ".kader"
351
+ / "memory"
352
+ / "sessions"
353
+ / main_session_id
354
+ / "executors"
355
+ / f"{self.name}-{execution_id}"
356
+ )
357
+ memory_file = memory_dir / "conversation.json"
358
+
359
+ memory = PersistentSlidingWindowConversationManager(
360
+ file_path=memory_file, window_size=20
361
+ )
362
+
363
+ # Load aggregated context from previous executors (async)
364
+ aggregated_context = await self._aload_aggregated_context(main_session_id)
365
+ if aggregated_context:
366
+ full_context = f"## Previous Executor Context\n{aggregated_context}\n\n## Current Task Context\n{context}"
367
+ else:
368
+ full_context = context
369
+
370
+ # Add context to memory as user message
371
+ memory.add_message(Message.user(full_context))
372
+
373
+ # Get default tools (filesystem, web, command executor) - use cached version
374
+ from kader.tools import get_cached_default_registry
375
+
376
+ tools = get_cached_default_registry()
377
+
378
+ # Create ExecutorAgentPrompt with tool descriptions
379
+ system_prompt = ExecutorAgentPrompt(tools=tools.tools)
380
+
381
+ # Create the ReActAgent with separate memory and executor prompt
382
+ agent = ReActAgent(
383
+ name=f"{self.name}_worker",
384
+ tools=tools,
385
+ system_prompt=system_prompt,
386
+ provider=self._provider,
387
+ memory=memory,
388
+ model_name=self._model_name,
389
+ interrupt_before_tool=self._interrupt_before_tool,
390
+ tool_confirmation_callback=self._tool_confirmation_callback,
391
+ direct_execution_callback=self._direct_execution_callback,
392
+ tool_execution_result_callback=self._tool_execution_result_callback,
393
+ )
394
+
395
+ try:
396
+ # Invoke the agent asynchronously
397
+ # The agent will handle tool interruptions internally
398
+ response = await agent.ainvoke(task)
399
+
400
+ # Generate checkpoint and aggregate it
401
+ try:
402
+ checkpointer = Checkpointer()
403
+ checkpoint_path = await checkpointer.agenerate_checkpoint(
404
+ str(memory_file)
405
+ )
406
+ checkpoint_content = await aread_text(Path(checkpoint_path))
407
+
408
+ # Aggregate the checkpoint into the main executors checkpoint
409
+ if main_session_id != "standalone":
410
+ aggregator = ContextAggregator(session_id=main_session_id)
411
+ # Use relative path from executors directory
412
+ relative_path = f"{self.name}-{execution_id}/checkpoint.md"
413
+ await aggregator.aaggregate(relative_path, subagent_name=self.name)
414
+
415
+ # Append the agent's response to the checkpoint content if it exists
416
+ response_content = None
417
+ if hasattr(response, "content"):
418
+ response_content = str(response.content)
419
+ elif isinstance(response, dict):
420
+ response_content = str(response.get("content", str(response)))
421
+ else:
422
+ response_content = str(response)
423
+
424
+ if response_content and response_content != "None":
425
+ checkpoint_content += f"\n\nResponse:\n{response_content}"
426
+
427
+ return checkpoint_content
428
+ except Exception:
429
+ # Fallback to raw response if checkpointing fails
430
+ if hasattr(response, "content"):
431
+ return str(response.content)
432
+ elif isinstance(response, dict):
433
+ return str(response.get("content", str(response)))
434
+ else:
435
+ return str(response)
436
+
437
+ except Exception as e:
438
+ return f"Agent execution failed: {str(e)}"
439
+
440
+ def get_interruption_message(self, task: str, **kwargs: Any) -> str:
441
+ """
442
+ Get a message describing the agent action for user confirmation.
443
+
444
+ Args:
445
+ task: The task the agent will execute.
446
+
447
+ Returns:
448
+ A formatted string describing the action.
449
+ """
450
+ # Truncate long tasks for readability
451
+ task_preview = task[:100] + "..." if len(task) > 100 else task
452
+ return f"execute {self.name}: {task_preview}"
kader/tools/filesys.py CHANGED
@@ -646,5 +646,5 @@ def get_filesystem_tools(
646
646
  EditFileTool(bp, virtual_mode),
647
647
  GrepTool(bp, virtual_mode),
648
648
  GlobTool(bp, virtual_mode),
649
- SearchInDirectoryTool(bp),
649
+ # SearchInDirectoryTool(bp), #TODO: remove search in directory from file system tools
650
650
  ]
kader/tools/todo.py CHANGED
@@ -52,7 +52,10 @@ class TodoTool(BaseTool[str]):
52
52
  "Manage todo lists for planning. "
53
53
  "Supports creating, reading, updating, and deleting todo lists. "
54
54
  "Each list is identified by a todo_id and contains items with status "
55
- "(not-started, in-progress, completed)."
55
+ "(not-started, in-progress, completed). "
56
+ "IMPORTANT: When updating, you can ONLY change the status of existing items. "
57
+ "You cannot add, remove, or modify task descriptions. "
58
+ "If you need to change tasks, delete and recreate the list."
56
59
  ),
57
60
  category=ToolCategory.UTILITY,
58
61
  parameters=[
@@ -185,7 +188,10 @@ class TodoTool(BaseTool[str]):
185
188
  def _update_todo(
186
189
  self, session_id: str, todo_id: str, items: list[TodoItem] | None
187
190
  ) -> str:
188
- """Update an existing todo list (overwrite)."""
191
+ """Update an existing todo list (status changes only).
192
+ This method enforces integrity by only allowing status updates.
193
+ The task descriptions must match the existing todo list exactly.
194
+ """
189
195
  path = self._get_todo_path(session_id, todo_id)
190
196
  if not path.exists():
191
197
  return f"Error: Todo list '{todo_id}' not found. Use 'create' to make a new list."
@@ -193,6 +199,26 @@ class TodoTool(BaseTool[str]):
193
199
  if items is None:
194
200
  return "Error: 'items' must be provided for update action."
195
201
 
202
+ # Read existing todo list
203
+ with open(path, "r", encoding="utf-8") as f:
204
+ try:
205
+ existing_data = json.load(f)
206
+ except json.JSONDecodeError:
207
+ return "Error: Failed to decode existing todo list JSON."
208
+
209
+ # Validate integrity: check that task descriptions match
210
+ existing_tasks = [item.get("task", "") for item in existing_data]
211
+ new_tasks = [item.task for item in items]
212
+
213
+ # Check if the number of items matches
214
+ if len(existing_tasks) != len(new_tasks):
215
+ return self._format_integrity_error(todo_id, existing_data)
216
+
217
+ # Check if all task descriptions match (order matters)
218
+ if existing_tasks != new_tasks:
219
+ return self._format_integrity_error(todo_id, existing_data)
220
+
221
+ # Validation passed - update with new statuses
196
222
  data = [item.model_dump() for item in items]
197
223
 
198
224
  with open(path, "w", encoding="utf-8") as f:
@@ -200,6 +226,21 @@ class TodoTool(BaseTool[str]):
200
226
 
201
227
  return f"Successfully updated todo list '{todo_id}'."
202
228
 
229
+ def _format_integrity_error(self, todo_id: str, existing_data: list[dict]) -> str:
230
+ """Format an integrity error message with the current todo list content."""
231
+ items_description = "\n".join(
232
+ f" {i + 1}. [{item.get('status', 'not-started')}] {item.get('task', '')}"
233
+ for i, item in enumerate(existing_data)
234
+ )
235
+ return (
236
+ f"Error: Update rejected - todo list integrity violation.\n"
237
+ f"The provided items do not match the existing todo list '{todo_id}'.\n"
238
+ f"You can only update the STATUS of existing items, not add, remove, or modify task descriptions.\n\n"
239
+ f"Current todo list '{todo_id}' content:\n{items_description}\n\n"
240
+ f"Please update using the exact task descriptions from the list above, "
241
+ f"only changing the 'status' field as needed."
242
+ )
243
+
203
244
  def _delete_todo(self, session_id: str, todo_id: str) -> str:
204
245
  """Delete a todo list."""
205
246
  path = self._get_todo_path(session_id, todo_id)
@@ -0,0 +1,10 @@
1
+ """
2
+ Utility modules for Kader.
3
+
4
+ Provides shared utility functions and helper modules.
5
+ """
6
+
7
+ from .checkpointer import Checkpointer
8
+ from .context_aggregator import ContextAggregator
9
+
10
+ __all__ = ["Checkpointer", "ContextAggregator"]