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.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. 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)