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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- 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)
|