turnloop 0.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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,139 @@
1
+ """Session state.
2
+
3
+ A Session owns the conversation, the todo list, and the accounting. It does not
4
+ own the model, the tools or the UI — a subagent gets its own Session and shares
5
+ nothing but the permission engine.
6
+
7
+ Two views of history exist deliberately:
8
+
9
+ * `messages` — what gets sent to the model, i.e. post-compaction.
10
+ * the JSONL on disk — every message ever, uncompacted.
11
+
12
+ Keeping the raw record is what makes `/rewind`, replay-mode experiments and
13
+ after-the-fact analysis possible. Compaction is a context-window strategy, not a
14
+ data-retention policy.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from datetime import UTC, datetime
21
+ from typing import Literal
22
+
23
+ from pydantic import BaseModel, Field
24
+
25
+ from turnloop.core.ids import new_id
26
+ from turnloop.core.messages import Message, ToolUseBlock, Usage
27
+
28
+ TodoStatus = Literal["pending", "in_progress", "completed"]
29
+
30
+
31
+ class Todo(BaseModel):
32
+ content: str = Field(description="Imperative form, e.g. 'Add the retry test'.")
33
+ activeForm: str = Field(
34
+ default="", description="Present continuous, e.g. 'Adding the retry test'."
35
+ )
36
+ status: TodoStatus = "pending"
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class CostState:
41
+ usage: Usage = field(default_factory=Usage)
42
+ cost_usd: float = 0.0
43
+ requests: int = 0
44
+ # Per-provider rollup, so a subagent on a cheaper model is visible separately.
45
+ by_provider: dict[str, float] = field(default_factory=dict)
46
+
47
+ def add(self, provider: str, usage: Usage, cost: float) -> None:
48
+ self.usage = self.usage + usage
49
+ self.cost_usd += cost
50
+ self.requests += 1
51
+ self.by_provider[provider] = self.by_provider.get(provider, 0.0) + cost
52
+
53
+
54
+ @dataclass
55
+ class Session:
56
+ session_id: str = field(default_factory=lambda: new_id("ses"))
57
+ started_at: datetime = field(default_factory=lambda: datetime.now(UTC))
58
+ provider: str = "mock"
59
+ model: str = ""
60
+ cwd: str = ""
61
+
62
+ messages: list[Message] = field(default_factory=list)
63
+ todos: list[Todo] = field(default_factory=list)
64
+ cost: CostState = field(default_factory=CostState)
65
+
66
+ turn: int = 0
67
+ compactions: int = 0
68
+ parent_id: str | None = None # set for subagent sessions
69
+ subagent_type: str | None = None
70
+
71
+ # Set by the store when persistence is enabled.
72
+ store: object | None = None
73
+
74
+ # --- history -----------------------------------------------------------
75
+
76
+ def append(self, message: Message) -> Message:
77
+ self.messages.append(message)
78
+ if self.store is not None:
79
+ self.store.write_message(message) # type: ignore[attr-defined]
80
+ return message
81
+
82
+ def replace_history(self, messages: list[Message]) -> None:
83
+ """Used by compaction. The on-disk log is not rewritten."""
84
+ self.messages = messages
85
+
86
+ @property
87
+ def last_assistant(self) -> Message | None:
88
+ for msg in reversed(self.messages):
89
+ if msg.role == "assistant":
90
+ return msg
91
+ return None
92
+
93
+ def pending_tool_uses(self) -> list[ToolUseBlock]:
94
+ """tool_use blocks in the last assistant message with no result yet.
95
+
96
+ An orphaned tool_use is a hard 400 from every provider, so this is checked
97
+ after an interrupted turn before the next request goes out.
98
+ """
99
+ last = self.last_assistant
100
+ if last is None:
101
+ return []
102
+ answered = {
103
+ b.tool_use_id
104
+ for msg in self.messages
105
+ for b in msg.tool_results
106
+ }
107
+ return [b for b in last.tool_uses if b.id not in answered]
108
+
109
+ # --- todos -------------------------------------------------------------
110
+
111
+ def set_todos(self, todos: list[Todo]) -> None:
112
+ self.todos = todos
113
+ if self.store is not None:
114
+ self.store.write_record("todos", {"todos": [t.model_dump() for t in todos]}) # type: ignore[attr-defined]
115
+
116
+ def todo_summary(self) -> str:
117
+ if not self.todos:
118
+ return ""
119
+ marks = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]"}
120
+ return "\n".join(f"{marks[t.status]} {t.content}" for t in self.todos)
121
+
122
+ # --- metadata ----------------------------------------------------------
123
+
124
+ def summary_line(self) -> str:
125
+ for msg in self.messages:
126
+ if msg.role == "user" and msg.text.strip():
127
+ return " ".join(msg.text.split())[:120]
128
+ return "(no user message)"
129
+
130
+ def meta(self) -> dict:
131
+ return {
132
+ "session_id": self.session_id,
133
+ "started_at": self.started_at.isoformat(),
134
+ "provider": self.provider,
135
+ "model": self.model,
136
+ "cwd": self.cwd,
137
+ "parent_id": self.parent_id,
138
+ "subagent_type": self.subagent_type,
139
+ }
@@ -0,0 +1,271 @@
1
+ """Session persistence: append-only JSONL, one record per line.
2
+
3
+ Append-only and flushed per line, so a hard kill (or a Ctrl-C during a 29-minute
4
+ cold boot) loses at most the record being written. A single JSON document
5
+ rewritten each turn would lose the whole session instead.
6
+
7
+ Compaction is recorded as an event rather than by rewriting history:
8
+
9
+ {"kind": "compaction", "payload": {"summary": ..., "replaced": [0, 42]}}
10
+
11
+ so replaying the log yields the compacted view the model actually saw, while the
12
+ raw messages remain available for experiments and for `/rewind`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from dataclasses import dataclass
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import Any, Literal
23
+
24
+ from turnloop.core.messages import Message
25
+ from turnloop.sessions.models import CostState, Session, Todo
26
+
27
+ RecordKind = Literal[
28
+ "meta", "message", "compaction", "todos", "tool_metrics",
29
+ "permission", "hook", "error", "usage",
30
+ ]
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class SessionInfo:
35
+ session_id: str
36
+ path: Path
37
+ started_at: datetime
38
+ provider: str
39
+ messages: int
40
+ summary: str
41
+
42
+
43
+ class SessionStore:
44
+ """Writes one session's JSONL. Reading is done by the classmethods."""
45
+
46
+ def __init__(self, path: Path, session: Session):
47
+ self.path = path
48
+ self.session = session
49
+ self._seq = 0
50
+ self.path.parent.mkdir(parents=True, exist_ok=True)
51
+ self._fh = self.path.open("a", encoding="utf-8", newline="\n")
52
+ self.write_record("meta", session.meta())
53
+
54
+ # --- writing -----------------------------------------------------------
55
+
56
+ def write_record(self, kind: RecordKind, payload: dict) -> None:
57
+ self._seq += 1
58
+ line = json.dumps(
59
+ {
60
+ "seq": self._seq,
61
+ "ts": datetime.now(UTC).isoformat(),
62
+ "kind": kind,
63
+ "payload": payload,
64
+ },
65
+ ensure_ascii=False,
66
+ default=str,
67
+ )
68
+ self._fh.write(line + "\n")
69
+ self._fh.flush()
70
+ # fsync every line would triple the cost of a tool-heavy turn; flushing
71
+ # is enough to survive a process kill, which is the failure we care about.
72
+
73
+ def write_message(self, message: Message) -> None:
74
+ self.write_record("message", message.model_dump(mode="json"))
75
+
76
+ def write_compaction(self, tier: str, summary: str, replaced: tuple[int, int],
77
+ tokens_before: int, tokens_after: int) -> None:
78
+ self.write_record(
79
+ "compaction",
80
+ {
81
+ "tier": tier,
82
+ "summary": summary,
83
+ "replaced": list(replaced),
84
+ "tokens_before": tokens_before,
85
+ "tokens_after": tokens_after,
86
+ },
87
+ )
88
+
89
+ def write_tool_metrics(self, name: str, metrics: dict[str, Any]) -> None:
90
+ self.write_record("tool_metrics", {"tool": name, **metrics})
91
+
92
+ def close(self) -> None:
93
+ try:
94
+ self._fh.close()
95
+ except Exception: # noqa: BLE001
96
+ pass
97
+
98
+ # --- creating / loading ------------------------------------------------
99
+
100
+ @staticmethod
101
+ def sessions_dir(project_root: Path) -> Path:
102
+ return project_root / ".turnloop" / "sessions"
103
+
104
+ @classmethod
105
+ def attach(cls, session: Session, project_root: Path) -> SessionStore:
106
+ path = cls.sessions_dir(project_root) / f"{session.session_id}.jsonl"
107
+ store = cls(path, session)
108
+ session.store = store
109
+ return store
110
+
111
+ @classmethod
112
+ def list_sessions(cls, project_root: Path) -> list[SessionInfo]:
113
+ directory = cls.sessions_dir(project_root)
114
+ if not directory.is_dir():
115
+ return []
116
+ infos: list[SessionInfo] = []
117
+ for path in directory.glob("*.jsonl"):
118
+ info = cls._peek(path)
119
+ if info is not None:
120
+ infos.append(info)
121
+ infos.sort(key=lambda i: i.started_at, reverse=True)
122
+ return infos
123
+
124
+ @classmethod
125
+ def _peek(cls, path: Path) -> SessionInfo | None:
126
+ """Read a session's headline data without parsing every message."""
127
+ provider = "?"
128
+ started = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
129
+ count = 0
130
+ summary = ""
131
+ try:
132
+ with path.open("r", encoding="utf-8") as fh:
133
+ for line in fh:
134
+ if not line.strip():
135
+ continue
136
+ try:
137
+ record = json.loads(line)
138
+ except json.JSONDecodeError:
139
+ continue
140
+ kind = record.get("kind")
141
+ payload = record.get("payload") or {}
142
+ if kind == "meta":
143
+ provider = payload.get("provider", provider)
144
+ if ts := payload.get("started_at"):
145
+ try:
146
+ started = datetime.fromisoformat(ts)
147
+ except ValueError:
148
+ pass
149
+ elif kind == "message":
150
+ count += 1
151
+ if not summary and payload.get("role") == "user":
152
+ text = "".join(
153
+ b.get("text", "")
154
+ for b in payload.get("content", [])
155
+ if b.get("type") == "text"
156
+ )
157
+ summary = " ".join(text.split())[:120]
158
+ except OSError:
159
+ return None
160
+ return SessionInfo(
161
+ session_id=path.stem,
162
+ path=path,
163
+ started_at=started,
164
+ provider=provider,
165
+ messages=count,
166
+ summary=summary,
167
+ )
168
+
169
+ @classmethod
170
+ def resume(cls, project_root: Path, session_id: str | None = None) -> Session | None:
171
+ """Rebuild a Session from its log, applying recorded compactions.
172
+
173
+ The returned session continues writing to the *same* file, so a resumed
174
+ conversation stays one coherent record rather than a chain of fragments.
175
+ """
176
+ if session_id in (None, "__last__"):
177
+ infos = cls.list_sessions(project_root)
178
+ if not infos:
179
+ return None
180
+ path = infos[0].path
181
+ else:
182
+ path = cls.sessions_dir(project_root) / f"{session_id}.jsonl"
183
+ if not path.is_file():
184
+ return None
185
+
186
+ session = Session(session_id=path.stem)
187
+ messages: list[Message] = []
188
+ todos: list[Todo] = []
189
+ cost = CostState()
190
+
191
+ with path.open("r", encoding="utf-8") as fh:
192
+ for line in fh:
193
+ if not line.strip():
194
+ continue
195
+ try:
196
+ record = json.loads(line)
197
+ except json.JSONDecodeError:
198
+ continue
199
+ kind = record.get("kind")
200
+ payload = record.get("payload") or {}
201
+
202
+ if kind == "meta":
203
+ session.provider = payload.get("provider", session.provider)
204
+ session.model = payload.get("model", session.model)
205
+ session.cwd = payload.get("cwd", session.cwd)
206
+ elif kind == "message":
207
+ try:
208
+ messages.append(Message.model_validate(payload))
209
+ except Exception: # noqa: BLE001 - skip a corrupt line, keep the session
210
+ continue
211
+ elif kind == "compaction":
212
+ lo, hi = (payload.get("replaced") or [0, 0])[:2]
213
+ tail = messages[hi:]
214
+ summary = payload.get("summary", "")
215
+ messages = (
216
+ [Message.user_text(summary, compaction=True), *tail] if summary else tail
217
+ )
218
+ session.compactions += 1
219
+ elif kind == "todos":
220
+ todos = [Todo.model_validate(t) for t in payload.get("todos", [])]
221
+ elif kind == "usage":
222
+ cost.add(
223
+ payload.get("provider", "?"),
224
+ _usage(payload),
225
+ float(payload.get("cost_usd", 0.0)),
226
+ )
227
+
228
+ session.messages = messages
229
+ session.todos = todos
230
+ session.cost = cost
231
+ session.turn = sum(1 for m in messages if m.role == "user")
232
+
233
+ store = cls(path, session)
234
+ session.store = store
235
+ return session
236
+
237
+
238
+ def _usage(payload: dict):
239
+ from turnloop.core.messages import Usage
240
+
241
+ return Usage(
242
+ input_tokens=payload.get("input_tokens", 0),
243
+ output_tokens=payload.get("output_tokens", 0),
244
+ cache_read_tokens=payload.get("cache_read_tokens", 0),
245
+ cache_write_tokens=payload.get("cache_write_tokens", 0),
246
+ )
247
+
248
+
249
+ def default_project_dir(project_root: Path) -> Path:
250
+ d = project_root / ".turnloop"
251
+ d.mkdir(parents=True, exist_ok=True)
252
+ gitignore = d / ".gitignore"
253
+ if not gitignore.exists():
254
+ # Sessions contain file contents and command output. They are local
255
+ # working data, not something to commit by accident.
256
+ gitignore.write_text("sessions/\nsettings.local.json\n", encoding="utf-8")
257
+ return d
258
+
259
+
260
+ def clear_sessions(project_root: Path) -> int:
261
+ directory = SessionStore.sessions_dir(project_root)
262
+ if not directory.is_dir():
263
+ return 0
264
+ removed = 0
265
+ for path in directory.glob("*.jsonl"):
266
+ try:
267
+ os.remove(path)
268
+ removed += 1
269
+ except OSError:
270
+ pass
271
+ return removed
File without changes
turnloop/tools/ask.py ADDED
@@ -0,0 +1,133 @@
1
+ """AskUserQuestion — a structured prompt with options.
2
+
3
+ Free-text questions are a poor interface in a terminal: the user has to type an
4
+ answer while a turn is parked, and the model gets prose it must re-interpret.
5
+ Options are answerable with one keystroke and unambiguous to consume.
6
+
7
+ The failure mode this tool must avoid is being used as a substitute for judgment.
8
+ Its description says so explicitly, and it is unavailable to subagents — a child
9
+ agent blocking on the parent's modal is a deadlock waiting to happen.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from turnloop.permissions.engine import PermissionRequest
17
+ from turnloop.tools.base import Tool, ToolContext, ToolOutput
18
+
19
+
20
+ class Option(BaseModel):
21
+ label: str = Field(description="Short choice text, 1-5 words.")
22
+ description: str = Field(default="", description="What choosing this means.")
23
+
24
+
25
+ class Question(BaseModel):
26
+ header: str = Field(description="Very short label, max 12 chars.")
27
+ question: str = Field(description="The full question.")
28
+ options: list[Option] = Field(description="2-4 mutually exclusive choices.")
29
+ multi_select: bool = Field(default=False, description="Allow several answers.")
30
+
31
+
32
+ class AskArgs(BaseModel):
33
+ questions: list[Question] = Field(description="1-4 questions to ask together.")
34
+
35
+
36
+ class AskUserQuestionTool(Tool):
37
+ name = "AskUserQuestion"
38
+ Args = AskArgs
39
+ read_only = True
40
+ parallel_safe = False
41
+ timeout_s = None # a human is answering; there is no meaningful timeout
42
+
43
+ descriptions = {
44
+ "terse": """
45
+ Ask the user to choose between options. Only when the answer genuinely changes
46
+ what you build — otherwise pick the sensible default and proceed.
47
+ """,
48
+ "normal": """
49
+ Ask the user a question with concrete options.
50
+
51
+ - Use this only when you are blocked on a decision that is genuinely theirs: two
52
+ readings of their request would lead to materially different work.
53
+ - Do not use it for choices with an obvious default, for facts you can determine
54
+ by reading the code, or to ask permission to continue.
55
+ - 1-4 questions, each with 2-4 options. Put your recommendation first and mark it
56
+ "(Recommended)".
57
+ - Ask everything you need in one call rather than interrogating turn by turn.
58
+ """,
59
+ "verbose": """
60
+ Ask the user a question with concrete options.
61
+
62
+ Arguments:
63
+ - `questions`: 1-4 questions. Each has `header` (a very short chip label, max 12
64
+ characters), `question` (the full text), `options` (2-4 choices with a `label`
65
+ and a `description` of what that choice implies), and `multi_select`.
66
+
67
+ When to use it:
68
+ - You are blocked on a decision that is the user's to make, where proceeding on an
69
+ assumption would either be unsafe or waste the work if the guess is wrong.
70
+ - Genuinely ambiguous requirements, where two readings lead to materially
71
+ different implementations.
72
+
73
+ When not to use it:
74
+ - The choice has a conventional default. Pick it, say you did, and continue.
75
+ - The answer is discoverable by reading the code or the configuration. Go and read.
76
+ - You want approval to proceed. Proceed; the permission system handles consent for
77
+ anything consequential.
78
+ - You are unsure whether your work is good. Verify it instead.
79
+
80
+ Guidance:
81
+ - Ask everything at once. A sequence of single questions is exhausting to answer.
82
+ - Order options with your recommendation first, labeled "(Recommended)".
83
+ - Make option descriptions say what will *happen*, not what the option *is*.
84
+ - The user can always answer something you did not list, so do not add an "other"
85
+ option.
86
+ """,
87
+ }
88
+
89
+ async def run(self, args: AskArgs, ctx: ToolContext) -> ToolOutput:
90
+ if not args.questions:
91
+ return ToolOutput.error("no questions supplied")
92
+ if ctx.depth > 0:
93
+ return ToolOutput.error(
94
+ "Subagents cannot ask the user questions — nobody is watching your "
95
+ "output. Make a reasonable assumption, state it in your final "
96
+ "message, and continue."
97
+ )
98
+
99
+ answers: list[str] = []
100
+ for question in args.questions:
101
+ request = PermissionRequest(
102
+ tool_name=self.name,
103
+ target=question.header,
104
+ summary=question.question,
105
+ args_preview=_render(question),
106
+ is_read_only=True,
107
+ suggested_rule="",
108
+ reason="choice",
109
+ )
110
+ decision = await ctx.ask(request)
111
+ if not decision.approved and not decision.reason:
112
+ answers.append(f"{question.header}: (the user dismissed this question)")
113
+ continue
114
+ answers.append(f"{question.header}: {decision.reason or '(no answer)'}")
115
+
116
+ return ToolOutput(
117
+ content="The user answered:\n" + "\n".join(answers),
118
+ display="\n".join(answers),
119
+ metrics={"questions": len(args.questions)},
120
+ )
121
+
122
+ def summary(self, args: AskArgs) -> str: # type: ignore[override]
123
+ first = args.questions[0].question if args.questions else ""
124
+ return f"Asking: {first[:60]}"
125
+
126
+
127
+ def _render(question: Question) -> str:
128
+ lines = [question.question, ""]
129
+ for i, option in enumerate(question.options, start=1):
130
+ lines.append(f"{i}. {option.label}")
131
+ if option.description:
132
+ lines.append(f" {option.description}")
133
+ return "\n".join(lines)