local-coding-agent 1.1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""Plan Mode Controller, Structured Questions & Dynamic Checklist (Milestone R27).
|
|
2
|
+
|
|
3
|
+
Adapted from DeepSeek Harness @deepseek-ai/dsh-plan-mode, @deepseek-ai/dsh-tool-ask-user,
|
|
4
|
+
and @deepseek-ai/dsh-tool-todo.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlanModeState(str, Enum):
|
|
16
|
+
INACTIVE = "inactive"
|
|
17
|
+
EXPLORING = "exploring"
|
|
18
|
+
PLAN_READY = "plan_ready"
|
|
19
|
+
APPROVED = "approved"
|
|
20
|
+
REJECTED = "rejected"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlanModeError(RuntimeError):
|
|
24
|
+
"""Base error for plan mode lifecycle violations."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlanModePolicyError(PlanModeError):
|
|
28
|
+
"""Raised when a blocked tool is invoked under plan mode restrictions."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PlanArtifact:
|
|
33
|
+
goal: str
|
|
34
|
+
steps: list[str] = field(default_factory=list)
|
|
35
|
+
risks: list[str] = field(default_factory=list)
|
|
36
|
+
files_to_modify: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"goal": self.goal,
|
|
41
|
+
"steps": list(self.steps),
|
|
42
|
+
"risks": list(self.risks),
|
|
43
|
+
"files_to_modify": list(self.files_to_modify),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict[str, Any]) -> PlanArtifact:
|
|
48
|
+
return cls(
|
|
49
|
+
goal=str(data.get("goal", "")),
|
|
50
|
+
steps=[str(s) for s in data.get("steps", [])],
|
|
51
|
+
risks=[str(r) for r in data.get("risks", [])],
|
|
52
|
+
files_to_modify=[str(f) for f in data.get("files_to_modify", [])],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def render_markdown(self) -> str:
|
|
56
|
+
lines = [f"# Plan: {self.goal}", ""]
|
|
57
|
+
lines.append("## Steps")
|
|
58
|
+
if self.steps:
|
|
59
|
+
for idx, step in enumerate(self.steps, 1):
|
|
60
|
+
lines.append(f"{idx}. {step}")
|
|
61
|
+
else:
|
|
62
|
+
lines.append("_No steps specified._")
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.append("## Risks")
|
|
65
|
+
if self.risks:
|
|
66
|
+
for risk in self.risks:
|
|
67
|
+
lines.append(f"- {risk}")
|
|
68
|
+
else:
|
|
69
|
+
lines.append("_No identified risks._")
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append("## Files to Modify")
|
|
72
|
+
if self.files_to_modify:
|
|
73
|
+
for f in self.files_to_modify:
|
|
74
|
+
lines.append(f"- `{f}`")
|
|
75
|
+
else:
|
|
76
|
+
lines.append("_None specified._")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Read-only tools allowed during plan exploration
|
|
81
|
+
READ_ONLY_TOOLS = frozenset({
|
|
82
|
+
"read_file",
|
|
83
|
+
"grep",
|
|
84
|
+
"search_text",
|
|
85
|
+
"ripgrep",
|
|
86
|
+
"lsp",
|
|
87
|
+
"list_files",
|
|
88
|
+
"ast_skeleton",
|
|
89
|
+
"doctor",
|
|
90
|
+
"ask_user_question",
|
|
91
|
+
"todo_write",
|
|
92
|
+
"todo_get",
|
|
93
|
+
"submit_plan",
|
|
94
|
+
"exit_plan_mode",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
# Write / mutation tools strictly blocked during exploration until plan is approved
|
|
98
|
+
WRITE_MUTATION_TOOLS = frozenset({
|
|
99
|
+
"propose_patch",
|
|
100
|
+
"apply",
|
|
101
|
+
"apply_patch",
|
|
102
|
+
"write_file",
|
|
103
|
+
"edit_file",
|
|
104
|
+
"delete_file",
|
|
105
|
+
"create_file",
|
|
106
|
+
"replace_file_content",
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PlanModeController:
|
|
111
|
+
"""Controls the lifecycle of plan mode, enforces tool policy, and manages approvals."""
|
|
112
|
+
|
|
113
|
+
def __init__(self) -> None:
|
|
114
|
+
self.state: PlanModeState = PlanModeState.INACTIVE
|
|
115
|
+
self.goal: str | None = None
|
|
116
|
+
self.current_plan: PlanArtifact | None = None
|
|
117
|
+
self.feedback: str | None = None
|
|
118
|
+
self.history: list[dict[str, Any]] = []
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def is_active(self) -> bool:
|
|
122
|
+
return self.state != PlanModeState.INACTIVE
|
|
123
|
+
|
|
124
|
+
def enter_plan_mode(self, goal: str) -> None:
|
|
125
|
+
if not goal or not isinstance(goal, str) or not goal.strip():
|
|
126
|
+
raise ValueError("goal must be a non-empty string")
|
|
127
|
+
self.goal = goal.strip()
|
|
128
|
+
self.state = PlanModeState.EXPLORING
|
|
129
|
+
self.current_plan = None
|
|
130
|
+
self.feedback = None
|
|
131
|
+
self._record_event("enter_plan_mode", {"goal": self.goal})
|
|
132
|
+
|
|
133
|
+
def exit_plan_mode(self) -> None:
|
|
134
|
+
self.state = PlanModeState.INACTIVE
|
|
135
|
+
self._record_event("exit_plan_mode", {})
|
|
136
|
+
|
|
137
|
+
def submit_plan(self, plan: PlanArtifact | dict[str, Any]) -> None:
|
|
138
|
+
if self.state not in {PlanModeState.EXPLORING, PlanModeState.PLAN_READY, PlanModeState.REJECTED}:
|
|
139
|
+
raise PlanModeError(
|
|
140
|
+
f"Cannot submit plan in state '{self.state.value}'. Must be in exploring or rejected state."
|
|
141
|
+
)
|
|
142
|
+
if isinstance(plan, dict):
|
|
143
|
+
plan_obj = PlanArtifact.from_dict(plan)
|
|
144
|
+
elif isinstance(plan, PlanArtifact):
|
|
145
|
+
plan_obj = plan
|
|
146
|
+
else:
|
|
147
|
+
raise ValueError("plan must be PlanArtifact or dict")
|
|
148
|
+
if not plan_obj.goal or not plan_obj.goal.strip():
|
|
149
|
+
raise ValueError("plan must have a non-empty goal")
|
|
150
|
+
self.current_plan = plan_obj
|
|
151
|
+
self.state = PlanModeState.PLAN_READY
|
|
152
|
+
self._record_event("submit_plan", plan_obj.to_dict())
|
|
153
|
+
|
|
154
|
+
def approve_plan(self) -> None:
|
|
155
|
+
if self.state != PlanModeState.PLAN_READY:
|
|
156
|
+
raise PlanModeError(
|
|
157
|
+
f"Cannot approve plan in state '{self.state.value}'. Plan must be in 'plan_ready' state."
|
|
158
|
+
)
|
|
159
|
+
self.state = PlanModeState.APPROVED
|
|
160
|
+
self._record_event("approve_plan", {})
|
|
161
|
+
|
|
162
|
+
def reject_plan(self, feedback: str) -> None:
|
|
163
|
+
if self.state != PlanModeState.PLAN_READY:
|
|
164
|
+
raise PlanModeError(
|
|
165
|
+
f"Cannot reject plan in state '{self.state.value}'. Plan must be in 'plan_ready' state."
|
|
166
|
+
)
|
|
167
|
+
if not feedback or not isinstance(feedback, str) or not feedback.strip():
|
|
168
|
+
raise ValueError("feedback must be a non-empty string")
|
|
169
|
+
self.feedback = feedback.strip()
|
|
170
|
+
self.state = PlanModeState.REJECTED
|
|
171
|
+
self._record_event("reject_plan", {"feedback": self.feedback})
|
|
172
|
+
|
|
173
|
+
def _is_safe_read_tool(self, tool: str) -> bool:
|
|
174
|
+
if tool in READ_ONLY_TOOLS:
|
|
175
|
+
return True
|
|
176
|
+
if tool in WRITE_MUTATION_TOOLS or tool in {
|
|
177
|
+
"propose_patch",
|
|
178
|
+
"apply",
|
|
179
|
+
"bash",
|
|
180
|
+
"terminal_execute",
|
|
181
|
+
"terminal_create",
|
|
182
|
+
"run_command",
|
|
183
|
+
"exec_command",
|
|
184
|
+
}:
|
|
185
|
+
return False
|
|
186
|
+
if (
|
|
187
|
+
tool.startswith("read_")
|
|
188
|
+
or tool.startswith("list_")
|
|
189
|
+
or tool.startswith("search_")
|
|
190
|
+
or tool.endswith("_search")
|
|
191
|
+
or tool.startswith("get_")
|
|
192
|
+
or tool.endswith("_get")
|
|
193
|
+
):
|
|
194
|
+
return True
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
def is_tool_allowed(self, tool_name: str) -> bool:
|
|
198
|
+
if not tool_name or not isinstance(tool_name, str):
|
|
199
|
+
return False
|
|
200
|
+
tool = tool_name.strip().lower()
|
|
201
|
+
if self.state in {PlanModeState.INACTIVE, PlanModeState.APPROVED}:
|
|
202
|
+
return True
|
|
203
|
+
if self.state in {PlanModeState.EXPLORING, PlanModeState.PLAN_READY, PlanModeState.REJECTED}:
|
|
204
|
+
return self._is_safe_read_tool(tool)
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def check_tool_allowed(self, tool_name: str) -> None:
|
|
208
|
+
if not self.is_tool_allowed(tool_name):
|
|
209
|
+
raise PlanModePolicyError(
|
|
210
|
+
f"Tool '{tool_name}' is blocked in {self.state.value} mode. "
|
|
211
|
+
f"Mutation/write tools are not permitted until the plan is approved."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def get_prompt_guidance(self) -> str:
|
|
215
|
+
if self.state == PlanModeState.EXPLORING:
|
|
216
|
+
return (
|
|
217
|
+
"PLAN MODE ACTIVE: You are in read-only planning exploration mode. "
|
|
218
|
+
"Investigate the codebase using read_file, search_text, grep, lsp, and list_files. "
|
|
219
|
+
"Do NOT propose patches or modify files until you produce and submit a plan using submit_plan "
|
|
220
|
+
"and the user approves it."
|
|
221
|
+
)
|
|
222
|
+
if self.state == PlanModeState.REJECTED:
|
|
223
|
+
return (
|
|
224
|
+
f"PLAN REJECTED: User feedback: {self.feedback}. "
|
|
225
|
+
"Please revise your investigation and submit an updated plan."
|
|
226
|
+
)
|
|
227
|
+
if self.state == PlanModeState.PLAN_READY:
|
|
228
|
+
return "PLAN SUBMITTED: Awaiting user approval."
|
|
229
|
+
if self.state == PlanModeState.APPROVED:
|
|
230
|
+
return "PLAN APPROVED: You may now execute the plan and propose changes."
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
def _record_event(self, action: str, data: dict[str, Any]) -> None:
|
|
234
|
+
self.history.append({"action": action, "state": self.state.value, "data": data})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass
|
|
238
|
+
class QuestionItem:
|
|
239
|
+
question: str
|
|
240
|
+
options: list[str] = field(default_factory=list)
|
|
241
|
+
is_multi_select: bool = False
|
|
242
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
|
243
|
+
header: str | None = None
|
|
244
|
+
|
|
245
|
+
def __post_init__(self) -> None:
|
|
246
|
+
if not self.id or not isinstance(self.id, str) or not self.id.strip():
|
|
247
|
+
self.id = str(uuid.uuid4())[:8]
|
|
248
|
+
else:
|
|
249
|
+
self.id = self.id.strip()
|
|
250
|
+
# Clean options: remove empty/whitespace-only options
|
|
251
|
+
cleaned_opts: list[str] = []
|
|
252
|
+
for opt in self.options:
|
|
253
|
+
s_opt = str(opt).strip()
|
|
254
|
+
if s_opt and s_opt not in cleaned_opts:
|
|
255
|
+
cleaned_opts.append(s_opt)
|
|
256
|
+
self.options = cleaned_opts
|
|
257
|
+
|
|
258
|
+
def to_dict(self) -> dict[str, Any]:
|
|
259
|
+
data: dict[str, Any] = {
|
|
260
|
+
"id": self.id,
|
|
261
|
+
"question": self.question,
|
|
262
|
+
"options": list(self.options),
|
|
263
|
+
"is_multi_select": self.is_multi_select,
|
|
264
|
+
}
|
|
265
|
+
if self.header:
|
|
266
|
+
data["header"] = self.header
|
|
267
|
+
return data
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def from_dict(cls, data: dict[str, Any]) -> QuestionItem:
|
|
271
|
+
raw_id = data.get("id")
|
|
272
|
+
q_id = str(raw_id).strip() if raw_id and str(raw_id).strip() else str(uuid.uuid4())[:8]
|
|
273
|
+
question = str(data.get("question", ""))
|
|
274
|
+
options = [str(o) for o in data.get("options", [])]
|
|
275
|
+
raw_multi = data.get("is_multi_select", data.get("multi_select", False))
|
|
276
|
+
if isinstance(raw_multi, str):
|
|
277
|
+
is_multi = raw_multi.strip().lower() in {"true", "1", "yes"}
|
|
278
|
+
elif isinstance(raw_multi, (int, float)):
|
|
279
|
+
is_multi = bool(raw_multi)
|
|
280
|
+
else:
|
|
281
|
+
is_multi = bool(raw_multi)
|
|
282
|
+
header = data.get("header")
|
|
283
|
+
return cls(
|
|
284
|
+
id=q_id,
|
|
285
|
+
question=question,
|
|
286
|
+
options=options,
|
|
287
|
+
is_multi_select=is_multi,
|
|
288
|
+
header=str(header) if header else None,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass
|
|
293
|
+
class QuestionAnswer:
|
|
294
|
+
id: str
|
|
295
|
+
selected: list[str] = field(default_factory=list)
|
|
296
|
+
custom: str | None = None
|
|
297
|
+
|
|
298
|
+
def to_dict(self) -> dict[str, Any]:
|
|
299
|
+
res: dict[str, Any] = {"id": self.id, "selected": list(self.selected)}
|
|
300
|
+
if self.custom is not None:
|
|
301
|
+
res["custom"] = self.custom
|
|
302
|
+
return res
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def from_dict(cls, data: dict[str, Any]) -> QuestionAnswer:
|
|
306
|
+
q_id = str(data.get("id", ""))
|
|
307
|
+
raw_sel = data.get("selected", [])
|
|
308
|
+
if isinstance(raw_sel, str):
|
|
309
|
+
selected = [raw_sel]
|
|
310
|
+
elif isinstance(raw_sel, list):
|
|
311
|
+
selected = [str(s) for s in raw_sel]
|
|
312
|
+
else:
|
|
313
|
+
selected = []
|
|
314
|
+
custom = data.get("custom")
|
|
315
|
+
return cls(id=q_id, selected=selected, custom=str(custom) if custom is not None else None)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class AskUserQuestionTool:
|
|
319
|
+
"""Tool allowing the model to ask structured questions with customizable response simulation."""
|
|
320
|
+
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
answer_provider: Callable[[QuestionItem], QuestionAnswer | dict[str, Any] | list[str] | str] | None = None,
|
|
324
|
+
preset_answers: dict[str, Any] | None = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
self.answer_provider = answer_provider
|
|
327
|
+
self.preset_answers: dict[str, Any] = dict(preset_answers or {})
|
|
328
|
+
self.history: list[dict[str, Any]] = []
|
|
329
|
+
|
|
330
|
+
def set_preset_answer(self, question_id_or_text: str, answer: Any) -> None:
|
|
331
|
+
self.preset_answers[question_id_or_text] = answer
|
|
332
|
+
|
|
333
|
+
def set_answer_provider(
|
|
334
|
+
self,
|
|
335
|
+
provider: Callable[[QuestionItem], QuestionAnswer | dict[str, Any] | list[str] | str] | None,
|
|
336
|
+
) -> None:
|
|
337
|
+
self.answer_provider = provider
|
|
338
|
+
|
|
339
|
+
def ask(self, questions: list[QuestionItem | dict[str, Any]]) -> list[dict[str, Any]]:
|
|
340
|
+
return self.ask_questions(questions)
|
|
341
|
+
|
|
342
|
+
def ask_questions(self, questions: list[QuestionItem | dict[str, Any]]) -> list[dict[str, Any]]:
|
|
343
|
+
if not questions:
|
|
344
|
+
raise ValueError("questions list cannot be empty")
|
|
345
|
+
parsed_questions: list[QuestionItem] = []
|
|
346
|
+
for q in questions:
|
|
347
|
+
if isinstance(q, QuestionItem):
|
|
348
|
+
parsed_questions.append(q)
|
|
349
|
+
elif isinstance(q, dict):
|
|
350
|
+
parsed_questions.append(QuestionItem.from_dict(q))
|
|
351
|
+
else:
|
|
352
|
+
raise ValueError("Each question must be a QuestionItem or dict")
|
|
353
|
+
|
|
354
|
+
seen_q_ids: set[str] = set()
|
|
355
|
+
for q in parsed_questions:
|
|
356
|
+
if not q.question or not q.question.strip():
|
|
357
|
+
raise ValueError("Question text must be non-empty")
|
|
358
|
+
if q.id in seen_q_ids:
|
|
359
|
+
raise ValueError(f"Duplicate question id '{q.id}' in questions list")
|
|
360
|
+
seen_q_ids.add(q.id)
|
|
361
|
+
|
|
362
|
+
results: list[dict[str, Any]] = []
|
|
363
|
+
for q in parsed_questions:
|
|
364
|
+
ans = self._resolve_answer(q)
|
|
365
|
+
results.append(ans.to_dict())
|
|
366
|
+
|
|
367
|
+
self.history.append({
|
|
368
|
+
"questions": [q.to_dict() for q in parsed_questions],
|
|
369
|
+
"answers": results,
|
|
370
|
+
})
|
|
371
|
+
return results
|
|
372
|
+
|
|
373
|
+
def _resolve_answer(self, q: QuestionItem) -> QuestionAnswer:
|
|
374
|
+
if q.id in self.preset_answers:
|
|
375
|
+
raw = self.preset_answers[q.id]
|
|
376
|
+
return self._normalize_answer(q, raw)
|
|
377
|
+
if q.question in self.preset_answers:
|
|
378
|
+
raw = self.preset_answers[q.question]
|
|
379
|
+
return self._normalize_answer(q, raw)
|
|
380
|
+
|
|
381
|
+
if self.answer_provider is not None:
|
|
382
|
+
raw = self.answer_provider(q)
|
|
383
|
+
return self._normalize_answer(q, raw)
|
|
384
|
+
|
|
385
|
+
if q.options:
|
|
386
|
+
return QuestionAnswer(id=q.id, selected=[q.options[0]])
|
|
387
|
+
return QuestionAnswer(id=q.id, selected=[], custom=None)
|
|
388
|
+
|
|
389
|
+
def _normalize_answer(self, q: QuestionItem, raw: Any) -> QuestionAnswer:
|
|
390
|
+
if isinstance(raw, QuestionAnswer):
|
|
391
|
+
ans = raw
|
|
392
|
+
elif isinstance(raw, dict):
|
|
393
|
+
ans = QuestionAnswer.from_dict({"id": q.id, **raw})
|
|
394
|
+
elif isinstance(raw, list):
|
|
395
|
+
cleaned_list: list[str] = []
|
|
396
|
+
for s in raw:
|
|
397
|
+
str_s = str(s).strip()
|
|
398
|
+
if str_s and str_s not in cleaned_list:
|
|
399
|
+
cleaned_list.append(str_s)
|
|
400
|
+
ans = QuestionAnswer(id=q.id, selected=cleaned_list)
|
|
401
|
+
elif isinstance(raw, str):
|
|
402
|
+
clean_str = raw.strip()
|
|
403
|
+
if q.options and clean_str in q.options:
|
|
404
|
+
ans = QuestionAnswer(id=q.id, selected=[clean_str])
|
|
405
|
+
else:
|
|
406
|
+
ans = QuestionAnswer(id=q.id, selected=[], custom=raw)
|
|
407
|
+
else:
|
|
408
|
+
ans = QuestionAnswer(id=q.id, selected=[])
|
|
409
|
+
|
|
410
|
+
if not q.is_multi_select and len(ans.selected) > 1:
|
|
411
|
+
raise ValueError(f"Question '{q.id}' does not allow multi-select, but got {len(ans.selected)} choices")
|
|
412
|
+
|
|
413
|
+
return ans
|
|
414
|
+
|
|
415
|
+
def execute(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
416
|
+
raw_questions = arguments.get("questions", [])
|
|
417
|
+
if not isinstance(raw_questions, list):
|
|
418
|
+
raise ValueError("arguments['questions'] must be a list")
|
|
419
|
+
answers = self.ask_questions(raw_questions)
|
|
420
|
+
return {"answers": answers}
|
|
421
|
+
|
|
422
|
+
def get_tool_definition(self) -> dict[str, Any]:
|
|
423
|
+
return {
|
|
424
|
+
"type": "function",
|
|
425
|
+
"function": {
|
|
426
|
+
"name": "ask_user_question",
|
|
427
|
+
"description": "Ask the user one or more clarifying questions before proceeding.",
|
|
428
|
+
"parameters": {
|
|
429
|
+
"type": "object",
|
|
430
|
+
"properties": {
|
|
431
|
+
"questions": {
|
|
432
|
+
"type": "array",
|
|
433
|
+
"items": {
|
|
434
|
+
"type": "object",
|
|
435
|
+
"properties": {
|
|
436
|
+
"id": {"type": "string"},
|
|
437
|
+
"question": {"type": "string"},
|
|
438
|
+
"header": {"type": "string"},
|
|
439
|
+
"options": {"type": "array", "items": {"type": "string"}},
|
|
440
|
+
"is_multi_select": {"type": "boolean"},
|
|
441
|
+
},
|
|
442
|
+
"required": ["question"],
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
"required": ["questions"],
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class TodoStatus(str, Enum):
|
|
453
|
+
PENDING = "pending"
|
|
454
|
+
IN_PROGRESS = "in_progress"
|
|
455
|
+
COMPLETED = "completed"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@dataclass
|
|
459
|
+
class TodoItem:
|
|
460
|
+
id: str
|
|
461
|
+
content: str
|
|
462
|
+
status: TodoStatus | str = TodoStatus.PENDING
|
|
463
|
+
|
|
464
|
+
def __post_init__(self) -> None:
|
|
465
|
+
if not self.id or not isinstance(self.id, str) or not self.id.strip():
|
|
466
|
+
raise ValueError("Todo id must be a non-empty string")
|
|
467
|
+
self.id = self.id.strip()
|
|
468
|
+
if not self.content or not isinstance(self.content, str) or not self.content.strip():
|
|
469
|
+
raise ValueError("Todo content must be a non-empty string")
|
|
470
|
+
self.content = self.content.strip()
|
|
471
|
+
if isinstance(self.status, str):
|
|
472
|
+
status_val = self.status.strip().lower()
|
|
473
|
+
if status_val not in {"pending", "in_progress", "completed"}:
|
|
474
|
+
raise ValueError(f"Invalid todo status '{self.status}'. Must be pending, in_progress, or completed.")
|
|
475
|
+
self.status = TodoStatus(status_val)
|
|
476
|
+
elif isinstance(self.status, TodoStatus):
|
|
477
|
+
pass
|
|
478
|
+
else:
|
|
479
|
+
raise ValueError(f"Invalid todo status '{self.status}'. Must be pending, in_progress, or completed.")
|
|
480
|
+
|
|
481
|
+
def to_dict(self) -> dict[str, Any]:
|
|
482
|
+
return {
|
|
483
|
+
"id": self.id,
|
|
484
|
+
"content": self.content,
|
|
485
|
+
"status": self.status.value if isinstance(self.status, TodoStatus) else str(self.status),
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
@classmethod
|
|
489
|
+
def from_dict(cls, data: dict[str, Any], fallback_id: str | None = None) -> TodoItem:
|
|
490
|
+
raw_id = data.get("id")
|
|
491
|
+
item_id = str(raw_id).strip() if raw_id and str(raw_id).strip() else (fallback_id or str(uuid.uuid4())[:8])
|
|
492
|
+
content = str(data.get("content", ""))
|
|
493
|
+
status = data.get("status", "pending")
|
|
494
|
+
return cls(id=item_id, content=content, status=status)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class TodoChecklist:
|
|
498
|
+
"""Dynamic Todo checklist tool with full-list replacement, ASCII and markdown rendering."""
|
|
499
|
+
|
|
500
|
+
def __init__(self, allow_parallel_in_progress: bool = True) -> None:
|
|
501
|
+
self.allow_parallel_in_progress = allow_parallel_in_progress
|
|
502
|
+
self._items: list[TodoItem] = []
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def items(self) -> list[TodoItem]:
|
|
506
|
+
return list(self._items)
|
|
507
|
+
|
|
508
|
+
def todo_write(self, items: list[dict[str, Any] | TodoItem]) -> list[dict[str, Any]]:
|
|
509
|
+
parsed_items: list[TodoItem] = []
|
|
510
|
+
seen_ids: set[str] = set()
|
|
511
|
+
seen_contents: set[str] = set()
|
|
512
|
+
active_count = 0
|
|
513
|
+
|
|
514
|
+
for idx, item in enumerate(items, 1):
|
|
515
|
+
if isinstance(item, TodoItem):
|
|
516
|
+
todo = item
|
|
517
|
+
elif isinstance(item, dict):
|
|
518
|
+
todo = TodoItem.from_dict(item, fallback_id=f"todo_{idx}")
|
|
519
|
+
else:
|
|
520
|
+
raise ValueError("Item must be TodoItem or dict")
|
|
521
|
+
|
|
522
|
+
if todo.id in seen_ids:
|
|
523
|
+
raise ValueError(f"Duplicate todo id: '{todo.id}'")
|
|
524
|
+
seen_ids.add(todo.id)
|
|
525
|
+
|
|
526
|
+
cleaned = todo.content.strip()
|
|
527
|
+
if cleaned in seen_contents:
|
|
528
|
+
raise ValueError(f"Duplicate todo content: '{cleaned}'")
|
|
529
|
+
seen_contents.add(cleaned)
|
|
530
|
+
|
|
531
|
+
if todo.status == TodoStatus.IN_PROGRESS:
|
|
532
|
+
active_count += 1
|
|
533
|
+
|
|
534
|
+
parsed_items.append(todo)
|
|
535
|
+
|
|
536
|
+
if not self.allow_parallel_in_progress and active_count > 1:
|
|
537
|
+
raise ValueError(f"At most one todo may be in_progress when parallel is disabled (got {active_count})")
|
|
538
|
+
|
|
539
|
+
self._items = parsed_items
|
|
540
|
+
return self.todo_get()
|
|
541
|
+
|
|
542
|
+
def todo_get(self) -> list[dict[str, Any]]:
|
|
543
|
+
return [item.to_dict() for item in self._items]
|
|
544
|
+
|
|
545
|
+
def todo_update(self, item_id: str, *, status: str | None = None, content: str | None = None) -> dict[str, Any]:
|
|
546
|
+
for idx, item in enumerate(self._items):
|
|
547
|
+
if item.id == item_id:
|
|
548
|
+
new_content = content if content is not None else item.content
|
|
549
|
+
new_status = status if status is not None else item.status
|
|
550
|
+
updated = TodoItem(id=item.id, content=new_content, status=new_status)
|
|
551
|
+
if not self.allow_parallel_in_progress and updated.status == TodoStatus.IN_PROGRESS:
|
|
552
|
+
other_active = any(
|
|
553
|
+
other.id != item_id and other.status == TodoStatus.IN_PROGRESS
|
|
554
|
+
for other in self._items
|
|
555
|
+
)
|
|
556
|
+
if other_active:
|
|
557
|
+
raise ValueError("At most one todo may be in_progress when parallel is disabled")
|
|
558
|
+
self._items[idx] = updated
|
|
559
|
+
return updated.to_dict()
|
|
560
|
+
raise KeyError(f"Todo item with id '{item_id}' not found")
|
|
561
|
+
|
|
562
|
+
def counts(self) -> dict[str, int]:
|
|
563
|
+
p = sum(1 for item in self._items if item.status == TodoStatus.PENDING)
|
|
564
|
+
ip = sum(1 for item in self._items if item.status == TodoStatus.IN_PROGRESS)
|
|
565
|
+
c = sum(1 for item in self._items if item.status == TodoStatus.COMPLETED)
|
|
566
|
+
return {
|
|
567
|
+
"pending": p,
|
|
568
|
+
"in_progress": ip,
|
|
569
|
+
"completed": c,
|
|
570
|
+
"total": len(self._items),
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
def render_ascii(self) -> str:
|
|
574
|
+
if not self._items:
|
|
575
|
+
return "No todos in checklist."
|
|
576
|
+
lines: list[str] = []
|
|
577
|
+
for item in self._items:
|
|
578
|
+
if item.status == TodoStatus.COMPLETED:
|
|
579
|
+
symbol = "[x]"
|
|
580
|
+
elif item.status == TodoStatus.IN_PROGRESS:
|
|
581
|
+
symbol = "[>]"
|
|
582
|
+
else:
|
|
583
|
+
symbol = "[ ]"
|
|
584
|
+
lines.append(f"{symbol} #{item.id} {item.content}")
|
|
585
|
+
c = self.counts()
|
|
586
|
+
lines.append("---")
|
|
587
|
+
lines.append(f"{c['completed']}/{c['total']} completed ({c['in_progress']} in progress, {c['pending']} pending)")
|
|
588
|
+
return "\n".join(lines)
|
|
589
|
+
|
|
590
|
+
def render_markdown(self) -> str:
|
|
591
|
+
if not self._items:
|
|
592
|
+
return "_No todos in checklist._"
|
|
593
|
+
lines: list[str] = []
|
|
594
|
+
for item in self._items:
|
|
595
|
+
if item.status == TodoStatus.COMPLETED:
|
|
596
|
+
lines.append(f"- [x] {item.content}")
|
|
597
|
+
elif item.status == TodoStatus.IN_PROGRESS:
|
|
598
|
+
lines.append(f"- [ ] **(in progress)** {item.content}")
|
|
599
|
+
else:
|
|
600
|
+
lines.append(f"- [ ] {item.content}")
|
|
601
|
+
return "\n".join(lines)
|
|
602
|
+
|
|
603
|
+
def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
604
|
+
if name == "todo_write":
|
|
605
|
+
raw_items = arguments.get("todos", arguments.get("items", []))
|
|
606
|
+
if not isinstance(raw_items, list):
|
|
607
|
+
raise ValueError("todos must be a list")
|
|
608
|
+
todos = self.todo_write(raw_items)
|
|
609
|
+
return {"todos": todos, "counts": self.counts()}
|
|
610
|
+
elif name == "todo_get":
|
|
611
|
+
return {"todos": self.todo_get(), "counts": self.counts()}
|
|
612
|
+
else:
|
|
613
|
+
raise ValueError(f"Unknown todo tool: {name}")
|
|
614
|
+
|
|
615
|
+
def get_tool_definitions(self) -> list[dict[str, Any]]:
|
|
616
|
+
return [
|
|
617
|
+
{
|
|
618
|
+
"type": "function",
|
|
619
|
+
"function": {
|
|
620
|
+
"name": "todo_write",
|
|
621
|
+
"description": "Record and update the structured task list for current work (replaces full list).",
|
|
622
|
+
"parameters": {
|
|
623
|
+
"type": "object",
|
|
624
|
+
"properties": {
|
|
625
|
+
"todos": {
|
|
626
|
+
"type": "array",
|
|
627
|
+
"items": {
|
|
628
|
+
"type": "object",
|
|
629
|
+
"properties": {
|
|
630
|
+
"id": {"type": "string"},
|
|
631
|
+
"content": {"type": "string"},
|
|
632
|
+
"status": {
|
|
633
|
+
"type": "string",
|
|
634
|
+
"enum": ["pending", "in_progress", "completed"],
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
"required": ["content", "status"],
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
"required": ["todos"],
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
"type": "function",
|
|
647
|
+
"function": {
|
|
648
|
+
"name": "todo_get",
|
|
649
|
+
"description": "Get current task list and status counts.",
|
|
650
|
+
"parameters": {"type": "object", "properties": {}},
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
]
|