yycode 0.3.2__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.
- agent/__init__.py +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- yycode-0.3.2.dist-info/top_level.txt +4 -0
agent/task_memory.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Deterministic task summary memory for long-running sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from langchain_core.messages import BaseMessage, HumanMessage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SUMMARY_MARKER = "[Task Summary Memory]"
|
|
13
|
+
SUMMARY_CONTEXT_POLICY = "summary_memory"
|
|
14
|
+
MERGED_SUMMARY_SOURCE = "automatic_merge"
|
|
15
|
+
MAX_MERGED_ITEMS_PER_SECTION = 24
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class TaskSummaryMemory:
|
|
20
|
+
"""A structured summary message for completed task context."""
|
|
21
|
+
|
|
22
|
+
content: str
|
|
23
|
+
covered_start_index: int
|
|
24
|
+
covered_end_index: int
|
|
25
|
+
source: str = "automatic"
|
|
26
|
+
|
|
27
|
+
def to_message(self) -> HumanMessage:
|
|
28
|
+
"""Return a provider-safe message containing the summary memory."""
|
|
29
|
+
message = HumanMessage(content=self.content)
|
|
30
|
+
message.additional_kwargs.update(
|
|
31
|
+
{
|
|
32
|
+
"context_policy": SUMMARY_CONTEXT_POLICY,
|
|
33
|
+
"summary_memory": True,
|
|
34
|
+
"covered_start_index": self.covered_start_index,
|
|
35
|
+
"covered_end_index": self.covered_end_index,
|
|
36
|
+
"source": self.source,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
return message
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TaskSummaryMemoryBuilder:
|
|
43
|
+
"""Build deterministic task summary memory from Task State facts."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, min_messages: int = 8) -> None:
|
|
46
|
+
self.min_messages = min_messages
|
|
47
|
+
|
|
48
|
+
def should_summarize(
|
|
49
|
+
self,
|
|
50
|
+
messages: list[BaseMessage],
|
|
51
|
+
*,
|
|
52
|
+
start_index: int,
|
|
53
|
+
task_state: dict[str, Any],
|
|
54
|
+
) -> bool:
|
|
55
|
+
"""Return whether a completed task range is worth summarizing."""
|
|
56
|
+
if start_index >= len(messages):
|
|
57
|
+
return False
|
|
58
|
+
if _latest_summary_index(messages, start_index) is not None:
|
|
59
|
+
return False
|
|
60
|
+
if len(messages) - start_index >= self.min_messages:
|
|
61
|
+
return True
|
|
62
|
+
return _task_state_has_facts(task_state)
|
|
63
|
+
|
|
64
|
+
def build(
|
|
65
|
+
self,
|
|
66
|
+
messages: list[BaseMessage],
|
|
67
|
+
*,
|
|
68
|
+
start_index: int,
|
|
69
|
+
task_state: dict[str, Any],
|
|
70
|
+
source: str = "automatic",
|
|
71
|
+
) -> TaskSummaryMemory:
|
|
72
|
+
"""Build summary memory for a completed task range."""
|
|
73
|
+
end_index = max(len(messages) - 1, start_index)
|
|
74
|
+
memory = task_state.get("memory") or {}
|
|
75
|
+
items = task_state.get("items") or []
|
|
76
|
+
lines = [
|
|
77
|
+
SUMMARY_MARKER,
|
|
78
|
+
"scope: current_session",
|
|
79
|
+
f"source: {source}",
|
|
80
|
+
f"created_at: {_utc_now()}",
|
|
81
|
+
f"covered_messages: {start_index}-{end_index}",
|
|
82
|
+
"",
|
|
83
|
+
]
|
|
84
|
+
_append_section(lines, "User Goal", _scalar_or_none(memory.get("user_goal")))
|
|
85
|
+
_append_list_section(lines, "Constraints", memory.get("constraints"))
|
|
86
|
+
_append_plan_section(lines, items)
|
|
87
|
+
_append_list_section(lines, "Decisions", memory.get("decisions"))
|
|
88
|
+
_append_files_section(
|
|
89
|
+
lines,
|
|
90
|
+
inspected=memory.get("files_inspected"),
|
|
91
|
+
modified=memory.get("files_modified"),
|
|
92
|
+
)
|
|
93
|
+
_append_list_section(lines, "Verification", memory.get("test_results"))
|
|
94
|
+
_append_list_section(lines, "Open Risks", memory.get("open_risks"))
|
|
95
|
+
_append_list_section(lines, "Next Steps", memory.get("next_steps"))
|
|
96
|
+
return TaskSummaryMemory(
|
|
97
|
+
content="\n".join(lines).rstrip() + "\n",
|
|
98
|
+
covered_start_index=start_index,
|
|
99
|
+
covered_end_index=end_index,
|
|
100
|
+
source=source,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_task_summary_memory(message: BaseMessage) -> bool:
|
|
105
|
+
"""Return whether a message is a task summary memory marker."""
|
|
106
|
+
additional_kwargs = getattr(message, "additional_kwargs", {}) or {}
|
|
107
|
+
if additional_kwargs.get("summary_memory") is True:
|
|
108
|
+
return True
|
|
109
|
+
content = getattr(message, "content", "")
|
|
110
|
+
return isinstance(content, str) and content.startswith(SUMMARY_MARKER)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_merged_task_summary_memory(
|
|
114
|
+
messages: list[BaseMessage],
|
|
115
|
+
*,
|
|
116
|
+
covered_start_index: int = 0,
|
|
117
|
+
covered_end_index: int | None = None,
|
|
118
|
+
source: str = MERGED_SUMMARY_SOURCE,
|
|
119
|
+
) -> TaskSummaryMemory:
|
|
120
|
+
"""Merge old completed task summaries and requests into one compact memory."""
|
|
121
|
+
end_index = covered_end_index if covered_end_index is not None else max(len(messages) - 1, 0)
|
|
122
|
+
facts: dict[str, list[str]] = {
|
|
123
|
+
"previous_requests": [],
|
|
124
|
+
"constraints": [],
|
|
125
|
+
"current_plan": [],
|
|
126
|
+
"decisions": [],
|
|
127
|
+
"files_inspected": [],
|
|
128
|
+
"files_modified": [],
|
|
129
|
+
"verification": [],
|
|
130
|
+
"open_risks": [],
|
|
131
|
+
"next_steps": [],
|
|
132
|
+
}
|
|
133
|
+
for message in messages:
|
|
134
|
+
if is_task_summary_memory(message):
|
|
135
|
+
_merge_summary_sections(facts, _message_text(message))
|
|
136
|
+
elif isinstance(message, HumanMessage):
|
|
137
|
+
_append_unique(facts["previous_requests"], _preview(_message_text(message), 180))
|
|
138
|
+
|
|
139
|
+
lines = [
|
|
140
|
+
SUMMARY_MARKER,
|
|
141
|
+
"scope: current_session",
|
|
142
|
+
f"source: {source}",
|
|
143
|
+
f"created_at: {_utc_now()}",
|
|
144
|
+
f"covered_messages: {covered_start_index}-{end_index}",
|
|
145
|
+
"",
|
|
146
|
+
"## User Goal",
|
|
147
|
+
"Merged completed task history from earlier conversation.",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
_append_list_section(lines, "Previous Requests", facts["previous_requests"])
|
|
151
|
+
_append_list_section(lines, "Constraints", facts["constraints"])
|
|
152
|
+
_append_list_section(lines, "Current Plan", facts["current_plan"])
|
|
153
|
+
_append_list_section(lines, "Decisions", facts["decisions"])
|
|
154
|
+
_append_files_section(
|
|
155
|
+
lines,
|
|
156
|
+
inspected=facts["files_inspected"],
|
|
157
|
+
modified=facts["files_modified"],
|
|
158
|
+
)
|
|
159
|
+
_append_list_section(lines, "Verification", facts["verification"])
|
|
160
|
+
_append_list_section(lines, "Open Risks", facts["open_risks"])
|
|
161
|
+
_append_list_section(lines, "Next Steps", facts["next_steps"])
|
|
162
|
+
return TaskSummaryMemory(
|
|
163
|
+
content="\n".join(lines).rstrip() + "\n",
|
|
164
|
+
covered_start_index=covered_start_index,
|
|
165
|
+
covered_end_index=end_index,
|
|
166
|
+
source=source,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _latest_summary_index(messages: list[BaseMessage], start_index: int) -> int | None:
|
|
171
|
+
for index, message in enumerate(messages[start_index:], start=start_index):
|
|
172
|
+
if is_task_summary_memory(message):
|
|
173
|
+
return index
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _merge_summary_sections(facts: dict[str, list[str]], content: str) -> None:
|
|
178
|
+
current = ""
|
|
179
|
+
file_mode = ""
|
|
180
|
+
for raw_line in content.splitlines():
|
|
181
|
+
line = raw_line.strip()
|
|
182
|
+
if not line:
|
|
183
|
+
continue
|
|
184
|
+
if line.startswith("## "):
|
|
185
|
+
current = line[3:].strip().lower()
|
|
186
|
+
file_mode = ""
|
|
187
|
+
continue
|
|
188
|
+
if line in {SUMMARY_MARKER} or ":" in line and current == "":
|
|
189
|
+
continue
|
|
190
|
+
if current == "user goal":
|
|
191
|
+
if line != "none recorded":
|
|
192
|
+
_append_unique(facts["previous_requests"], _preview(_strip_bullet(line), 180))
|
|
193
|
+
elif current == "constraints":
|
|
194
|
+
_append_fact(facts["constraints"], line)
|
|
195
|
+
elif current == "current plan":
|
|
196
|
+
_append_fact(facts["current_plan"], line)
|
|
197
|
+
elif current == "decisions":
|
|
198
|
+
_append_fact(facts["decisions"], line)
|
|
199
|
+
elif current == "files":
|
|
200
|
+
normalized = line.rstrip(":").lower()
|
|
201
|
+
if normalized == "inspected":
|
|
202
|
+
file_mode = "files_inspected"
|
|
203
|
+
elif normalized == "modified":
|
|
204
|
+
file_mode = "files_modified"
|
|
205
|
+
elif file_mode:
|
|
206
|
+
_append_fact(facts[file_mode], line)
|
|
207
|
+
elif current == "verification":
|
|
208
|
+
_append_fact(facts["verification"], line)
|
|
209
|
+
elif current == "open risks":
|
|
210
|
+
_append_fact(facts["open_risks"], line)
|
|
211
|
+
elif current == "next steps":
|
|
212
|
+
_append_fact(facts["next_steps"], line)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _append_fact(values: list[str], line: str) -> None:
|
|
216
|
+
value = _strip_bullet(line)
|
|
217
|
+
if value and value != "none recorded":
|
|
218
|
+
_append_unique(values, _preview(value, 220))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _append_unique(values: list[str], value: str) -> None:
|
|
222
|
+
normalized = " ".join(str(value or "").split())
|
|
223
|
+
if not normalized or normalized in values:
|
|
224
|
+
return
|
|
225
|
+
if len(values) >= MAX_MERGED_ITEMS_PER_SECTION:
|
|
226
|
+
return
|
|
227
|
+
values.append(normalized)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _strip_bullet(line: str) -> str:
|
|
231
|
+
text = line.strip()
|
|
232
|
+
return text[2:].strip() if text.startswith("- ") else text
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _message_text(message: BaseMessage) -> str:
|
|
236
|
+
content = getattr(message, "content", "")
|
|
237
|
+
if isinstance(content, str):
|
|
238
|
+
return content
|
|
239
|
+
if isinstance(content, list):
|
|
240
|
+
return "\n".join(str(item) for item in content)
|
|
241
|
+
return str(content)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _preview(text: str, limit: int) -> str:
|
|
245
|
+
value = " ".join(str(text or "").split())
|
|
246
|
+
if len(value) <= limit:
|
|
247
|
+
return value
|
|
248
|
+
return value[: max(0, limit - 3)] + "..."
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _task_state_has_facts(task_state: dict[str, Any]) -> bool:
|
|
252
|
+
items = task_state.get("items")
|
|
253
|
+
if isinstance(items, list) and any(isinstance(item, dict) for item in items):
|
|
254
|
+
return True
|
|
255
|
+
memory = task_state.get("memory") or {}
|
|
256
|
+
if not isinstance(memory, dict):
|
|
257
|
+
return False
|
|
258
|
+
if _scalar_or_none(memory.get("user_goal")):
|
|
259
|
+
return True
|
|
260
|
+
for value in memory.values():
|
|
261
|
+
if isinstance(value, list) and any(str(item).strip() for item in value):
|
|
262
|
+
return True
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _append_section(lines: list[str], title: str, value: str | None) -> None:
|
|
267
|
+
lines.extend([f"## {title}", value or "none recorded", ""])
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _append_list_section(lines: list[str], title: str, values: Any) -> None:
|
|
271
|
+
lines.append(f"## {title}")
|
|
272
|
+
normalized = _normalize_list(values)
|
|
273
|
+
if normalized:
|
|
274
|
+
lines.extend(f"- {value}" for value in normalized)
|
|
275
|
+
else:
|
|
276
|
+
lines.append("none recorded")
|
|
277
|
+
lines.append("")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _append_plan_section(lines: list[str], items: Any) -> None:
|
|
281
|
+
lines.append("## Current Plan")
|
|
282
|
+
if not isinstance(items, list) or not items:
|
|
283
|
+
lines.append("none recorded")
|
|
284
|
+
lines.append("")
|
|
285
|
+
return
|
|
286
|
+
for item in items:
|
|
287
|
+
if not isinstance(item, dict):
|
|
288
|
+
continue
|
|
289
|
+
status = str(item.get("status") or "pending").strip()
|
|
290
|
+
text = str(item.get("text") or "").strip()
|
|
291
|
+
item_id = str(item.get("id") or "").strip()
|
|
292
|
+
label = f"{status}: {text}" if text else status
|
|
293
|
+
if item_id:
|
|
294
|
+
label = f"[{item_id}] {label}"
|
|
295
|
+
lines.append(f"- {label}")
|
|
296
|
+
lines.append("")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _append_files_section(lines: list[str], *, inspected: Any, modified: Any) -> None:
|
|
300
|
+
lines.append("## Files")
|
|
301
|
+
inspected_values = _normalize_list(inspected)
|
|
302
|
+
modified_values = _normalize_list(modified)
|
|
303
|
+
lines.append("Inspected:")
|
|
304
|
+
if inspected_values:
|
|
305
|
+
lines.extend(f"- {value}" for value in inspected_values)
|
|
306
|
+
else:
|
|
307
|
+
lines.append("- none recorded")
|
|
308
|
+
lines.append("")
|
|
309
|
+
lines.append("Modified:")
|
|
310
|
+
if modified_values:
|
|
311
|
+
lines.extend(f"- {value}" for value in modified_values)
|
|
312
|
+
else:
|
|
313
|
+
lines.append("- none recorded")
|
|
314
|
+
lines.append("")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _normalize_list(values: Any) -> list[str]:
|
|
318
|
+
if values is None:
|
|
319
|
+
return []
|
|
320
|
+
if isinstance(values, str):
|
|
321
|
+
values = [values]
|
|
322
|
+
if not isinstance(values, list):
|
|
323
|
+
return []
|
|
324
|
+
normalized = []
|
|
325
|
+
for value in values:
|
|
326
|
+
text = str(value).strip()
|
|
327
|
+
if text:
|
|
328
|
+
normalized.append(text)
|
|
329
|
+
return normalized
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _scalar_or_none(value: Any) -> str | None:
|
|
333
|
+
if not isinstance(value, str):
|
|
334
|
+
return None
|
|
335
|
+
normalized = value.strip()
|
|
336
|
+
return normalized or None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _utc_now() -> str:
|
|
340
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
agent/todo_manager.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""TodoManager - Manages task state and tracking logic."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import List, Dict, Any, Optional, Callable
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TodoManager:
|
|
10
|
+
"""Manages task state and provides the todo tool handler."""
|
|
11
|
+
|
|
12
|
+
MAX_ITEMS = 20 # Maximum allowed todo items
|
|
13
|
+
MEMORY_LIST_FIELDS = (
|
|
14
|
+
"constraints",
|
|
15
|
+
"files_inspected",
|
|
16
|
+
"files_modified",
|
|
17
|
+
"decisions",
|
|
18
|
+
"test_results",
|
|
19
|
+
"open_risks",
|
|
20
|
+
"next_steps",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.todo_items: List[Dict[str, Any]] = []
|
|
25
|
+
self.memory: Dict[str, Any] = self._empty_memory()
|
|
26
|
+
self.consecutive_non_todo_rounds: int = 0
|
|
27
|
+
self.task_state_started: bool = False
|
|
28
|
+
self.task_completed: bool = False
|
|
29
|
+
self.completed_items: List[Dict[str, Any]] = []
|
|
30
|
+
self.last_incomplete_signature: Optional[tuple] = None
|
|
31
|
+
self.repeated_incomplete_updates: int = 0
|
|
32
|
+
|
|
33
|
+
def _empty_memory(self) -> Dict[str, Any]:
|
|
34
|
+
"""Return an empty task memory shape."""
|
|
35
|
+
memory = {"user_goal": ""}
|
|
36
|
+
for field in self.MEMORY_LIST_FIELDS:
|
|
37
|
+
memory[field] = []
|
|
38
|
+
return memory
|
|
39
|
+
|
|
40
|
+
def get_items(self) -> List[Dict[str, Any]]:
|
|
41
|
+
"""Get current todo items."""
|
|
42
|
+
return self.todo_items
|
|
43
|
+
|
|
44
|
+
def get_memory(self) -> Dict[str, Any]:
|
|
45
|
+
"""Get current compact task memory."""
|
|
46
|
+
return {
|
|
47
|
+
key: list(value) if isinstance(value, list) else value
|
|
48
|
+
for key, value in self.memory.items()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def get_task_state(self) -> Dict[str, Any]:
|
|
52
|
+
"""Get the complete task state."""
|
|
53
|
+
return {
|
|
54
|
+
"items": list(self.todo_items or self.completed_items),
|
|
55
|
+
"memory": self.get_memory(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def set_items(self, items: List[Dict[str, Any]]) -> None:
|
|
59
|
+
"""Set todo items and check if all are completed or over limit."""
|
|
60
|
+
# Check for maximum items limit
|
|
61
|
+
if len(items) > self.MAX_ITEMS:
|
|
62
|
+
logger.warning(f"Todo list exceeds maximum of {self.MAX_ITEMS} items. Truncated.")
|
|
63
|
+
items = items[:self.MAX_ITEMS]
|
|
64
|
+
|
|
65
|
+
signature = self._items_signature(items)
|
|
66
|
+
is_incomplete = bool(items) and not self._items_all_completed(items)
|
|
67
|
+
if is_incomplete and signature == self.last_incomplete_signature:
|
|
68
|
+
self.repeated_incomplete_updates += 1
|
|
69
|
+
else:
|
|
70
|
+
self.repeated_incomplete_updates = 0
|
|
71
|
+
self.last_incomplete_signature = signature if is_incomplete else None
|
|
72
|
+
|
|
73
|
+
self.todo_items = items
|
|
74
|
+
if items:
|
|
75
|
+
self.task_state_started = True
|
|
76
|
+
self.task_completed = False
|
|
77
|
+
# Check if all items are completed - if yes, clear the list
|
|
78
|
+
if items and self._all_completed():
|
|
79
|
+
self._clear_on_completion()
|
|
80
|
+
|
|
81
|
+
def set_memory(self, memory: Optional[Dict[str, Any]]) -> None:
|
|
82
|
+
"""Merge compact task memory into the current state."""
|
|
83
|
+
if not memory:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
user_goal = memory.get("user_goal")
|
|
87
|
+
if isinstance(user_goal, str) and user_goal.strip():
|
|
88
|
+
self.memory["user_goal"] = user_goal.strip()
|
|
89
|
+
|
|
90
|
+
for field in self.MEMORY_LIST_FIELDS:
|
|
91
|
+
values = memory.get(field)
|
|
92
|
+
if values is None:
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(values, str):
|
|
95
|
+
values = [values]
|
|
96
|
+
if not isinstance(values, list):
|
|
97
|
+
continue
|
|
98
|
+
current = self.memory.setdefault(field, [])
|
|
99
|
+
for value in values:
|
|
100
|
+
if not isinstance(value, str):
|
|
101
|
+
continue
|
|
102
|
+
normalized = value.strip()
|
|
103
|
+
if normalized and normalized not in current:
|
|
104
|
+
current.append(normalized)
|
|
105
|
+
|
|
106
|
+
def _all_completed(self) -> bool:
|
|
107
|
+
"""Check if all todo items are completed."""
|
|
108
|
+
return self._items_all_completed(self.todo_items)
|
|
109
|
+
|
|
110
|
+
def _items_all_completed(self, items: List[Dict[str, Any]]) -> bool:
|
|
111
|
+
"""Check if all provided todo items are completed."""
|
|
112
|
+
if not items:
|
|
113
|
+
return False
|
|
114
|
+
return all(item.get("status") == "completed" for item in items)
|
|
115
|
+
|
|
116
|
+
def _items_signature(self, items: List[Dict[str, Any]]) -> tuple:
|
|
117
|
+
"""Return a stable signature for detecting repeated incomplete updates."""
|
|
118
|
+
return tuple(
|
|
119
|
+
(
|
|
120
|
+
str(item.get("id", "")),
|
|
121
|
+
str(item.get("text", "")),
|
|
122
|
+
str(item.get("status", "")),
|
|
123
|
+
)
|
|
124
|
+
for item in items
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _clear_on_completion(self) -> None:
|
|
128
|
+
"""Clear todo list when all items are completed."""
|
|
129
|
+
logger.info("All tasks completed! Todo list has been cleared.")
|
|
130
|
+
self.completed_items = list(self.todo_items)
|
|
131
|
+
self.todo_items = []
|
|
132
|
+
self.task_completed = True
|
|
133
|
+
self.consecutive_non_todo_rounds = 0
|
|
134
|
+
|
|
135
|
+
def reset(self) -> None:
|
|
136
|
+
"""Reset todo state."""
|
|
137
|
+
self.todo_items = []
|
|
138
|
+
self.memory = self._empty_memory()
|
|
139
|
+
self.consecutive_non_todo_rounds = 0
|
|
140
|
+
self.task_state_started = False
|
|
141
|
+
self.task_completed = False
|
|
142
|
+
self.completed_items = []
|
|
143
|
+
self.last_incomplete_signature = None
|
|
144
|
+
self.repeated_incomplete_updates = 0
|
|
145
|
+
|
|
146
|
+
def clear(self) -> None:
|
|
147
|
+
"""Explicitly clear todo list."""
|
|
148
|
+
self.todo_items = []
|
|
149
|
+
self.memory = self._empty_memory()
|
|
150
|
+
self.consecutive_non_todo_rounds = 0
|
|
151
|
+
self.task_state_started = False
|
|
152
|
+
self.task_completed = False
|
|
153
|
+
self.completed_items = []
|
|
154
|
+
self.last_incomplete_signature = None
|
|
155
|
+
self.repeated_incomplete_updates = 0
|
|
156
|
+
|
|
157
|
+
def prepare_for_new_input(self) -> None:
|
|
158
|
+
"""Prepare for a new user input - clear previous tasks for new planning."""
|
|
159
|
+
if self.todo_items and not self._all_completed():
|
|
160
|
+
logger.info("Starting new task, previous todo list cleared.")
|
|
161
|
+
self.todo_items = []
|
|
162
|
+
self.memory = self._empty_memory()
|
|
163
|
+
self.consecutive_non_todo_rounds = 0
|
|
164
|
+
self.task_state_started = False
|
|
165
|
+
self.task_completed = False
|
|
166
|
+
self.completed_items = []
|
|
167
|
+
self.last_incomplete_signature = None
|
|
168
|
+
self.repeated_incomplete_updates = 0
|
|
169
|
+
|
|
170
|
+
def can_finish_task(self) -> bool:
|
|
171
|
+
"""Return whether the current task may finish normally."""
|
|
172
|
+
return self.task_state_started and self.task_completed
|
|
173
|
+
|
|
174
|
+
def has_incomplete_task_state(self) -> bool:
|
|
175
|
+
"""Return whether task state is missing or has unfinished items."""
|
|
176
|
+
return not self.can_finish_task()
|
|
177
|
+
|
|
178
|
+
def get_finish_blocker_message(self) -> str:
|
|
179
|
+
"""Return a message that forces task state creation/completion before exit."""
|
|
180
|
+
if not self.task_state_started:
|
|
181
|
+
return (
|
|
182
|
+
"Task State is required before you can finish this user request. "
|
|
183
|
+
"Call todo now, even if the task only decomposes into one item. "
|
|
184
|
+
"Create a concise checklist and set exactly one item in_progress."
|
|
185
|
+
)
|
|
186
|
+
return (
|
|
187
|
+
"You cannot finish yet because Task State still has unfinished work. "
|
|
188
|
+
"Continue executing the remaining todo items. When all work and verification "
|
|
189
|
+
"are complete, call todo with every item marked completed so the task can exit."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def record_tool_call(self, tool_name: str) -> None:
|
|
193
|
+
"""Record a tool call for tracking."""
|
|
194
|
+
if tool_name == "todo":
|
|
195
|
+
self.consecutive_non_todo_rounds = 0
|
|
196
|
+
else:
|
|
197
|
+
self.consecutive_non_todo_rounds += 1
|
|
198
|
+
|
|
199
|
+
def needs_reminder(self) -> bool:
|
|
200
|
+
"""Check if todo reminder is needed (3 rounds without todo)."""
|
|
201
|
+
return self.consecutive_non_todo_rounds >= 3 and len(self.todo_items) > 0
|
|
202
|
+
|
|
203
|
+
def get_reminder_message(self) -> str:
|
|
204
|
+
"""Get the reminder message with current task status."""
|
|
205
|
+
if not self.todo_items:
|
|
206
|
+
return ""
|
|
207
|
+
|
|
208
|
+
status_list = []
|
|
209
|
+
for item in self.todo_items:
|
|
210
|
+
status_icon = {
|
|
211
|
+
"pending": "[ ]",
|
|
212
|
+
"in_progress": "[~]",
|
|
213
|
+
"completed": "[X]",
|
|
214
|
+
}.get(item["status"], "[ ]")
|
|
215
|
+
status_list.append(f"{status_icon} [{item['id']}] {item['text']}")
|
|
216
|
+
|
|
217
|
+
task_status = "\n".join(status_list)
|
|
218
|
+
memory_status = self._format_memory()
|
|
219
|
+
|
|
220
|
+
return (f"\n\n[Reminder: You haven't updated your task list in "
|
|
221
|
+
f"{self.consecutive_non_todo_rounds} rounds. Current task state:\n"
|
|
222
|
+
f"{task_status}\n"
|
|
223
|
+
f"{memory_status}\n"
|
|
224
|
+
f"Consider using the todo tool to update progress and memory.]")
|
|
225
|
+
|
|
226
|
+
def consume_reminder_message(self) -> str:
|
|
227
|
+
"""Return one reminder and reset the reminder counter."""
|
|
228
|
+
reminder = self.get_reminder_message()
|
|
229
|
+
if reminder:
|
|
230
|
+
self.consecutive_non_todo_rounds = 0
|
|
231
|
+
return reminder
|
|
232
|
+
|
|
233
|
+
def has_repeated_incomplete_update(self) -> bool:
|
|
234
|
+
"""Return whether the same incomplete todo state was repeated."""
|
|
235
|
+
return self.repeated_incomplete_updates >= 1 and bool(self.todo_items)
|
|
236
|
+
|
|
237
|
+
def consume_repeated_incomplete_message(self) -> str:
|
|
238
|
+
"""Return a no-progress warning and reset the repeated update counter."""
|
|
239
|
+
if not self.has_repeated_incomplete_update():
|
|
240
|
+
return ""
|
|
241
|
+
self.repeated_incomplete_updates = 0
|
|
242
|
+
return (
|
|
243
|
+
"Task State did not change: you repeated the same incomplete todo list. "
|
|
244
|
+
"Do not call todo again with the same in_progress item. Take the next concrete "
|
|
245
|
+
"action now, such as running a verification tool or inspecting the relevant "
|
|
246
|
+
"file. If the work is already verified or no further automated verification is "
|
|
247
|
+
"possible, call todo with all items marked completed and then provide the final answer."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def create_todo_handler(self) -> Callable:
|
|
251
|
+
"""Create a todo handler bound to this manager."""
|
|
252
|
+
def todo(items, memory=None):
|
|
253
|
+
"""Update task state and display current progress."""
|
|
254
|
+
submitted_items = list(items or [])
|
|
255
|
+
self.set_items(items)
|
|
256
|
+
self.set_memory(memory)
|
|
257
|
+
|
|
258
|
+
display_items = self.todo_items or submitted_items
|
|
259
|
+
|
|
260
|
+
result = []
|
|
261
|
+
result.append("Task State:")
|
|
262
|
+
result.append("-" * 40)
|
|
263
|
+
|
|
264
|
+
for item in display_items:
|
|
265
|
+
status_icon = {
|
|
266
|
+
"pending": "[ ]",
|
|
267
|
+
"in_progress": "[~]",
|
|
268
|
+
"completed": "[X]",
|
|
269
|
+
}.get(item["status"], "[ ]")
|
|
270
|
+
result.append(f"{status_icon} [{item['id']}] {item['text']}")
|
|
271
|
+
|
|
272
|
+
if not self.todo_items and submitted_items:
|
|
273
|
+
result.append("All tasks completed! Todo list has been cleared.")
|
|
274
|
+
|
|
275
|
+
result.append("-" * 40)
|
|
276
|
+
memory_text = self._format_memory()
|
|
277
|
+
if memory_text:
|
|
278
|
+
result.append(memory_text)
|
|
279
|
+
output = "\n".join(result)
|
|
280
|
+
logger.info(f"Task state updated:\n{output}")
|
|
281
|
+
print(f"\n{output}\n") # Keep for user interface
|
|
282
|
+
return output
|
|
283
|
+
return todo
|
|
284
|
+
|
|
285
|
+
def _format_memory(self) -> str:
|
|
286
|
+
"""Format compact memory for reminders and tool output."""
|
|
287
|
+
lines = []
|
|
288
|
+
user_goal = self.memory.get("user_goal", "")
|
|
289
|
+
if user_goal:
|
|
290
|
+
lines.append(f"Goal: {user_goal}")
|
|
291
|
+
labels = {
|
|
292
|
+
"constraints": "Constraints",
|
|
293
|
+
"files_inspected": "Files inspected",
|
|
294
|
+
"files_modified": "Files modified",
|
|
295
|
+
"decisions": "Decisions",
|
|
296
|
+
"test_results": "Test results",
|
|
297
|
+
"open_risks": "Open risks",
|
|
298
|
+
"next_steps": "Next steps",
|
|
299
|
+
}
|
|
300
|
+
for field, label in labels.items():
|
|
301
|
+
values = self.memory.get(field, [])
|
|
302
|
+
if values:
|
|
303
|
+
lines.append(f"{label}: " + "; ".join(values))
|
|
304
|
+
return "\n".join(lines)
|