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.
- cli/app.py +98 -61
- cli/app.tcss +27 -382
- cli/utils.py +1 -6
- cli/widgets/conversation.py +50 -4
- kader/__init__.py +2 -0
- kader/agent/agents.py +8 -0
- kader/agent/base.py +68 -5
- kader/memory/types.py +60 -0
- kader/prompts/__init__.py +9 -1
- kader/prompts/agent_prompts.py +28 -0
- kader/prompts/templates/executor_agent.j2 +70 -0
- kader/prompts/templates/kader_planner.j2 +71 -0
- kader/providers/ollama.py +2 -2
- kader/tools/__init__.py +26 -0
- kader/tools/agent.py +452 -0
- kader/tools/filesys.py +1 -1
- kader/tools/todo.py +43 -2
- kader/utils/__init__.py +10 -0
- kader/utils/checkpointer.py +371 -0
- kader/utils/context_aggregator.py +347 -0
- kader/workflows/__init__.py +13 -0
- kader/workflows/base.py +71 -0
- kader/workflows/planner_executor.py +251 -0
- {kader-0.1.5.dist-info → kader-1.0.0.dist-info}/METADATA +38 -1
- {kader-0.1.5.dist-info → kader-1.0.0.dist-info}/RECORD +27 -18
- {kader-0.1.5.dist-info → kader-1.0.0.dist-info}/WHEEL +0 -0
- {kader-0.1.5.dist-info → kader-1.0.0.dist-info}/entry_points.txt +0 -0
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
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 (
|
|
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)
|
kader/utils/__init__.py
ADDED