whiletrue 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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Stdio MCP server exposing whiletrue's agent tools.
|
|
2
|
+
|
|
3
|
+
Passed to Claude Code with `--mcp-config`. It holds no logic of its own: the tool list and every
|
|
4
|
+
call are forwarded to the whiletrue process, which validates results against the step's output
|
|
5
|
+
caps. Standard library only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import IO, Any
|
|
15
|
+
|
|
16
|
+
PROTOCOL_FALLBACK = "2024-11-05"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def post_json(url: str, path: str, body: Any, token: str, session: str) -> Any:
|
|
20
|
+
request = urllib.request.Request(
|
|
21
|
+
url + path,
|
|
22
|
+
data=json.dumps(body).encode(),
|
|
23
|
+
headers={"X-WT-Token": token, "X-WT-Session": session, "Content-Type": "application/json"},
|
|
24
|
+
)
|
|
25
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
26
|
+
return json.loads(response.read() or b"{}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _reply(message_id: Any, result: Any) -> dict[str, Any]:
|
|
30
|
+
return {"jsonrpc": "2.0", "id": message_id, "result": result}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def handle(message: dict[str, Any], env: Mapping[str, str]) -> dict[str, Any] | None:
|
|
34
|
+
"""Answers one JSON-RPC message (None for notifications)."""
|
|
35
|
+
method, message_id = message.get("method"), message.get("id")
|
|
36
|
+
if message_id is None:
|
|
37
|
+
return None
|
|
38
|
+
url, token, session = (
|
|
39
|
+
env.get("WT_ENDPOINT", ""),
|
|
40
|
+
env.get("WT_TOKEN", ""),
|
|
41
|
+
env.get("WT_SESSION", ""),
|
|
42
|
+
)
|
|
43
|
+
params: dict[str, Any] = message.get("params") or {}
|
|
44
|
+
if method == "initialize":
|
|
45
|
+
return _reply(
|
|
46
|
+
message_id,
|
|
47
|
+
{
|
|
48
|
+
"protocolVersion": params.get("protocolVersion", PROTOCOL_FALLBACK),
|
|
49
|
+
"capabilities": {"tools": {}},
|
|
50
|
+
"serverInfo": {"name": "whiletrue", "version": "0.1.0"},
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
if method == "ping":
|
|
54
|
+
return _reply(message_id, {})
|
|
55
|
+
if method == "tools/list":
|
|
56
|
+
try:
|
|
57
|
+
tools = post_json(url, "/tools/list", {}, token, session).get("tools", [])
|
|
58
|
+
except Exception:
|
|
59
|
+
tools = []
|
|
60
|
+
return _reply(message_id, {"tools": tools})
|
|
61
|
+
if method == "tools/call":
|
|
62
|
+
try:
|
|
63
|
+
answer = post_json(
|
|
64
|
+
url,
|
|
65
|
+
"/tools/call",
|
|
66
|
+
{"name": params.get("name"), "arguments": params.get("arguments") or {}},
|
|
67
|
+
token,
|
|
68
|
+
session,
|
|
69
|
+
)
|
|
70
|
+
text, is_error = str(answer.get("text", "")), bool(answer.get("is_error"))
|
|
71
|
+
except Exception as error:
|
|
72
|
+
text, is_error = f"whiletrue is unreachable: {error}", True
|
|
73
|
+
return _reply(
|
|
74
|
+
message_id, {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
75
|
+
)
|
|
76
|
+
return {
|
|
77
|
+
"jsonrpc": "2.0",
|
|
78
|
+
"id": message_id,
|
|
79
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def serve(stdin: IO[str], stdout: IO[str], env: Mapping[str, str]) -> None:
|
|
84
|
+
for line in stdin:
|
|
85
|
+
if not line.strip():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
message = json.loads(line)
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
response = handle(message, env) if isinstance(message, dict) else None # pyright: ignore[reportUnknownArgumentType]
|
|
92
|
+
if response is not None:
|
|
93
|
+
stdout.write(json.dumps(response) + "\n")
|
|
94
|
+
stdout.flush()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__": # pragma: no cover
|
|
98
|
+
serve(sys.stdin, sys.stdout, __import__("os").environ)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from whiletrue.claude_code.endpoint import ToolResult
|
|
11
|
+
from whiletrue.claude_code.learnings import LearningsStore
|
|
12
|
+
from whiletrue.claude_code.transcript import Price, Usage, read_usage
|
|
13
|
+
from whiletrue.core import AgentLimits, CapValidation
|
|
14
|
+
|
|
15
|
+
#: Why a session was parked rather than finished.
|
|
16
|
+
ParkReason = Literal["needs_human", "budget_exceeded", "turns_exceeded", "wall_exceeded"]
|
|
17
|
+
|
|
18
|
+
SUBMIT_TOOL = "whiletrue_submit"
|
|
19
|
+
LEARN_TOOL = "whiletrue_learn"
|
|
20
|
+
ASK_TOOL = "whiletrue_ask"
|
|
21
|
+
CONTEXT_TOOL = "whiletrue_context"
|
|
22
|
+
|
|
23
|
+
_NUDGE = (
|
|
24
|
+
"You have not submitted a result yet. Call the whiletrue_submit tool now with your result "
|
|
25
|
+
"(it is how this step finishes). If you are blocked, call whiletrue_ask with your question."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SessionOutcome:
|
|
31
|
+
#: `submitted`: a result arrived; `parked`: the step needs a person (see `reason`); `ended`:
|
|
32
|
+
#: the session closed without a result.
|
|
33
|
+
status: Literal["submitted", "parked", "ended"]
|
|
34
|
+
value: Any = None
|
|
35
|
+
#: The agent's last message.
|
|
36
|
+
text: str = ""
|
|
37
|
+
reason: ParkReason | None = None
|
|
38
|
+
detail: str | None = None
|
|
39
|
+
session_id: str | None = None
|
|
40
|
+
steered: bool = False
|
|
41
|
+
turns: int = 0
|
|
42
|
+
cost_usd: float = 0.0
|
|
43
|
+
steered_cost_usd: float = 0.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(kw_only=True)
|
|
47
|
+
class SessionSpec:
|
|
48
|
+
#: The step key the pane is registered under.
|
|
49
|
+
key: str
|
|
50
|
+
item: dict[str, Any] = field(default_factory=lambda: {})
|
|
51
|
+
#: Validates a submitted result against the step's output cap (None: accept anything).
|
|
52
|
+
validate: Callable[[Any], CapValidation] | None = None
|
|
53
|
+
result_cap: str | None = None
|
|
54
|
+
result_schema: dict[str, Any] | None = None
|
|
55
|
+
limits: AgentLimits | None = None
|
|
56
|
+
learnings: LearningsStore | None = None
|
|
57
|
+
#: Extra fields stored with every learning (run, item, step).
|
|
58
|
+
provenance: dict[str, Any] = field(default_factory=lambda: {})
|
|
59
|
+
#: Extra context returned by `whiletrue_context` (for example spec excerpts).
|
|
60
|
+
extra_context: dict[str, Any] = field(default_factory=lambda: {})
|
|
61
|
+
max_nudges: int = 3
|
|
62
|
+
prices: dict[str, Price] | None = None
|
|
63
|
+
clock: Callable[[], float] = time.time
|
|
64
|
+
on_event: Callable[[dict[str, Any]], None] | None = None
|
|
65
|
+
on_attention: Callable[[str], None] | None = None
|
|
66
|
+
on_attention_cleared: Callable[[], None] | None = None
|
|
67
|
+
#: Called once when a limit is hit, so the runner can end the session.
|
|
68
|
+
on_limit: Callable[[ParkReason], None] | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StepSession:
|
|
72
|
+
"""The in-process half of one interactive agent session.
|
|
73
|
+
|
|
74
|
+
The plugin's hooks and MCP tools land here (through the endpoint). It decides when the step
|
|
75
|
+
is done, parks it when it needs a person or hits a limit, and records a steer when a human
|
|
76
|
+
types into the pane.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, spec: SessionSpec) -> None:
|
|
80
|
+
self._spec = spec
|
|
81
|
+
self._started = spec.clock()
|
|
82
|
+
self.outcome: asyncio.Future[SessionOutcome] = asyncio.get_running_loop().create_future()
|
|
83
|
+
self.session_id: str | None = None
|
|
84
|
+
self.transcript_path: str | None = None
|
|
85
|
+
self.submitted = False
|
|
86
|
+
self._value: Any = None
|
|
87
|
+
self._question: str | None = None
|
|
88
|
+
self._last_message = ""
|
|
89
|
+
self._nudges = 0
|
|
90
|
+
self._prompts = 0
|
|
91
|
+
self._steered_at: float | None = None
|
|
92
|
+
self.tool_calls = 0
|
|
93
|
+
self.needs_attention = False
|
|
94
|
+
self.limit_hit: ParkReason | None = None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def steered(self) -> bool:
|
|
98
|
+
return self._steered_at is not None
|
|
99
|
+
|
|
100
|
+
def _event(self, kind: str, **data: Any) -> None:
|
|
101
|
+
if self._spec.on_event is not None:
|
|
102
|
+
self._spec.on_event({"kind": kind, **data})
|
|
103
|
+
|
|
104
|
+
# ---- tools -----------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def tools(self) -> list[dict[str, Any]]:
|
|
107
|
+
spec = self._spec
|
|
108
|
+
cap = f" The result must match the {spec.result_cap} schema." if spec.result_cap else ""
|
|
109
|
+
result_schema: dict[str, Any] = spec.result_schema if spec.result_schema else {}
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
"name": SUBMIT_TOOL,
|
|
113
|
+
"description": "Finish this step by submitting your result. Call it when the "
|
|
114
|
+
f"work is complete.{cap} Invalid results are returned with the errors.",
|
|
115
|
+
"inputSchema": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"properties": {"result": result_schema},
|
|
118
|
+
"required": ["result"],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": LEARN_TOOL,
|
|
123
|
+
"description": "Record a short, reusable lesson about this project.",
|
|
124
|
+
"inputSchema": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {"note": {"type": "string"}},
|
|
127
|
+
"required": ["note"],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": ASK_TOOL,
|
|
132
|
+
"description": "Ask the human a question and stop. The step parks until answered.",
|
|
133
|
+
"inputSchema": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"properties": {"question": {"type": "string"}},
|
|
136
|
+
"required": ["question"],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": CONTEXT_TOOL,
|
|
141
|
+
"description": "Get this item's details and recent project learnings.",
|
|
142
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
async def on_tool(self, name: str, args: dict[str, Any]) -> ToolResult:
|
|
147
|
+
if name == SUBMIT_TOOL:
|
|
148
|
+
return self._submit(args)
|
|
149
|
+
if name == LEARN_TOOL:
|
|
150
|
+
return self._learn(args)
|
|
151
|
+
if name == ASK_TOOL:
|
|
152
|
+
question = args.get("question")
|
|
153
|
+
if not isinstance(question, str) or not question.strip():
|
|
154
|
+
return ToolResult("`question` is required", is_error=True)
|
|
155
|
+
self._question = question
|
|
156
|
+
self._event("ask", question=question)
|
|
157
|
+
return ToolResult("Recorded. Stop now; a person will answer and resume the work.")
|
|
158
|
+
if name == CONTEXT_TOOL:
|
|
159
|
+
learnings = self._spec.learnings.recent(20) if self._spec.learnings else []
|
|
160
|
+
body = {"item": self._spec.item, "learnings": learnings, **self._spec.extra_context}
|
|
161
|
+
return ToolResult(json.dumps(body))
|
|
162
|
+
return ToolResult(f'Unknown tool "{name}"', is_error=True)
|
|
163
|
+
|
|
164
|
+
def _submit(self, args: dict[str, Any]) -> ToolResult:
|
|
165
|
+
if "result" not in args:
|
|
166
|
+
return ToolResult("`result` is required", is_error=True)
|
|
167
|
+
value: Any = args["result"]
|
|
168
|
+
if self._spec.validate is not None:
|
|
169
|
+
check = self._spec.validate(value)
|
|
170
|
+
if not check.ok:
|
|
171
|
+
self._event("submit_rejected", errors=check.errors)
|
|
172
|
+
return ToolResult(
|
|
173
|
+
"Result rejected, fix these and call whiletrue_submit again:\n"
|
|
174
|
+
+ "\n".join(f"- {e}" for e in check.errors),
|
|
175
|
+
is_error=True,
|
|
176
|
+
)
|
|
177
|
+
value = check.value
|
|
178
|
+
self._value = value
|
|
179
|
+
self.submitted = True
|
|
180
|
+
self._event("submit")
|
|
181
|
+
return ToolResult("Result accepted.")
|
|
182
|
+
|
|
183
|
+
def _learn(self, args: dict[str, Any]) -> ToolResult:
|
|
184
|
+
note = args.get("note")
|
|
185
|
+
if not isinstance(note, str) or not note.strip():
|
|
186
|
+
return ToolResult("`note` is required", is_error=True)
|
|
187
|
+
if self._spec.learnings is None:
|
|
188
|
+
return ToolResult("Learnings are not enabled for this run", is_error=True)
|
|
189
|
+
self._spec.learnings.append(note.strip(), **self._spec.provenance)
|
|
190
|
+
self._event("learn")
|
|
191
|
+
return ToolResult("Noted.")
|
|
192
|
+
|
|
193
|
+
# ---- hooks -----------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
async def on_hook(self, event: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
196
|
+
match event:
|
|
197
|
+
case "SessionStart":
|
|
198
|
+
self.session_id = payload.get("session_id") or self.session_id
|
|
199
|
+
self.transcript_path = payload.get("transcript_path") or self.transcript_path
|
|
200
|
+
self._event("session_start", session_id=self.session_id)
|
|
201
|
+
case "UserPromptSubmit":
|
|
202
|
+
self._on_prompt(str(payload.get("prompt", "")))
|
|
203
|
+
case "PreToolUse":
|
|
204
|
+
return self._on_pre_tool(payload)
|
|
205
|
+
case "PostToolUse":
|
|
206
|
+
self.tool_calls += 1
|
|
207
|
+
self._clear_attention()
|
|
208
|
+
self._event("tool_use", name=payload.get("tool_name", ""))
|
|
209
|
+
case "Notification":
|
|
210
|
+
message = str(payload.get("message", ""))
|
|
211
|
+
self.needs_attention = True
|
|
212
|
+
self._event("attention", message=message)
|
|
213
|
+
if self._spec.on_attention is not None:
|
|
214
|
+
self._spec.on_attention(message)
|
|
215
|
+
case "Stop":
|
|
216
|
+
return self._on_stop(payload)
|
|
217
|
+
case "SessionEnd":
|
|
218
|
+
self._on_end()
|
|
219
|
+
case _:
|
|
220
|
+
pass
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def _clear_attention(self) -> None:
|
|
224
|
+
if self.needs_attention:
|
|
225
|
+
self.needs_attention = False
|
|
226
|
+
if self._spec.on_attention_cleared is not None:
|
|
227
|
+
self._spec.on_attention_cleared()
|
|
228
|
+
|
|
229
|
+
def _on_prompt(self, prompt: str) -> None:
|
|
230
|
+
self._prompts += 1
|
|
231
|
+
self._clear_attention()
|
|
232
|
+
if self._prompts > 1 and self._steered_at is None:
|
|
233
|
+
# The prompt we launched with is the first; anything later was typed by a person.
|
|
234
|
+
self._steered_at = self._spec.clock()
|
|
235
|
+
if self._prompts > 1:
|
|
236
|
+
self._event("steer", prompt=prompt)
|
|
237
|
+
|
|
238
|
+
def _usage(self) -> Usage:
|
|
239
|
+
return read_usage(self.transcript_path, self._spec.prices, steered_since=self._steered_at)
|
|
240
|
+
|
|
241
|
+
def _check_limits(self) -> ParkReason | None:
|
|
242
|
+
limits = self._spec.limits
|
|
243
|
+
if limits is None or self.steered:
|
|
244
|
+
return None # a person driving the session lifts every limit
|
|
245
|
+
if (
|
|
246
|
+
limits.wall_ms is not None
|
|
247
|
+
and (self._spec.clock() - self._started) * 1000 >= limits.wall_ms
|
|
248
|
+
):
|
|
249
|
+
return "wall_exceeded"
|
|
250
|
+
if limits.budget_usd is None and limits.turns is None:
|
|
251
|
+
return None
|
|
252
|
+
usage = self._usage()
|
|
253
|
+
if limits.budget_usd is not None and usage.cost_usd >= limits.budget_usd:
|
|
254
|
+
return "budget_exceeded"
|
|
255
|
+
if limits.turns is not None and usage.turns > limits.turns:
|
|
256
|
+
return "turns_exceeded"
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
def _on_pre_tool(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
260
|
+
if self.limit_hit is None:
|
|
261
|
+
hit = self._check_limits()
|
|
262
|
+
if hit is None:
|
|
263
|
+
return None
|
|
264
|
+
self.limit_hit = hit
|
|
265
|
+
self._event("limit", reason=hit)
|
|
266
|
+
if self._spec.on_limit is not None:
|
|
267
|
+
self._spec.on_limit(hit)
|
|
268
|
+
elif self.steered:
|
|
269
|
+
return None
|
|
270
|
+
label = {
|
|
271
|
+
"budget_exceeded": "the budget estimate was exceeded",
|
|
272
|
+
"turns_exceeded": "the turn limit was reached",
|
|
273
|
+
"wall_exceeded": "the wall-time limit was reached",
|
|
274
|
+
}.get(self.limit_hit, "a limit was reached")
|
|
275
|
+
return {
|
|
276
|
+
"hookSpecificOutput": {
|
|
277
|
+
"hookEventName": "PreToolUse",
|
|
278
|
+
"permissionDecision": "deny",
|
|
279
|
+
"permissionDecisionReason": f"whiletrue: {label}; this step is being parked.",
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
def _on_stop(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
284
|
+
if self.outcome.done():
|
|
285
|
+
return None
|
|
286
|
+
self._last_message = str(payload.get("last_assistant_message", self._last_message))
|
|
287
|
+
if self.limit_hit is not None and not self.steered:
|
|
288
|
+
self._finish("parked", reason=self.limit_hit, detail=f"limit hit: {self.limit_hit}")
|
|
289
|
+
elif self._question is not None:
|
|
290
|
+
self._finish("parked", reason="needs_human", detail=self._question)
|
|
291
|
+
elif self.submitted:
|
|
292
|
+
self._finish("submitted")
|
|
293
|
+
elif self.steered:
|
|
294
|
+
return None # a person is driving: waiting for them is not a failure
|
|
295
|
+
elif self._nudges < self._spec.max_nudges:
|
|
296
|
+
self._nudges += 1
|
|
297
|
+
self._event("nudge", count=self._nudges)
|
|
298
|
+
return {"decision": "block", "reason": _NUDGE}
|
|
299
|
+
else:
|
|
300
|
+
self._finish(
|
|
301
|
+
"parked",
|
|
302
|
+
reason="needs_human",
|
|
303
|
+
detail="the agent stopped without submitting a result after "
|
|
304
|
+
f"{self._nudges} reminder(s)",
|
|
305
|
+
)
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
def park(self, reason: ParkReason, detail: str) -> None:
|
|
309
|
+
"""Parks the step from outside (the runner's wall-time timer, a dead pane, ...)."""
|
|
310
|
+
if self.outcome.done():
|
|
311
|
+
return
|
|
312
|
+
self.limit_hit = reason if reason != "needs_human" else self.limit_hit
|
|
313
|
+
self._finish("parked", reason=reason, detail=detail)
|
|
314
|
+
|
|
315
|
+
def _on_end(self) -> None:
|
|
316
|
+
if self.outcome.done():
|
|
317
|
+
return
|
|
318
|
+
if self.submitted:
|
|
319
|
+
self._finish("submitted")
|
|
320
|
+
else:
|
|
321
|
+
self._finish("ended", detail="the session ended without submitting a result")
|
|
322
|
+
|
|
323
|
+
def _finish(
|
|
324
|
+
self,
|
|
325
|
+
status: Literal["submitted", "parked", "ended"],
|
|
326
|
+
*,
|
|
327
|
+
reason: ParkReason | None = None,
|
|
328
|
+
detail: str | None = None,
|
|
329
|
+
) -> None:
|
|
330
|
+
usage = self._usage()
|
|
331
|
+
self._event("done", status=status)
|
|
332
|
+
self.outcome.set_result(
|
|
333
|
+
SessionOutcome(
|
|
334
|
+
status=status,
|
|
335
|
+
value=self._value,
|
|
336
|
+
text=self._last_message,
|
|
337
|
+
reason=reason,
|
|
338
|
+
detail=detail,
|
|
339
|
+
session_id=self.session_id,
|
|
340
|
+
steered=self.steered,
|
|
341
|
+
turns=usage.turns,
|
|
342
|
+
cost_usd=usage.cost_usd,
|
|
343
|
+
steered_cost_usd=usage.steered_cost_usd,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Price:
|
|
12
|
+
"""USD per million tokens."""
|
|
13
|
+
|
|
14
|
+
input: float
|
|
15
|
+
output: float
|
|
16
|
+
cache_read: float
|
|
17
|
+
cache_write: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#: Rough list prices by model family, matched by substring of the model id. These drive the
|
|
21
|
+
#: *estimate* that enforces budgets in interactive sessions; they are not billing data.
|
|
22
|
+
DEFAULT_PRICES: dict[str, Price] = {
|
|
23
|
+
"opus": Price(input=15.0, output=75.0, cache_read=1.5, cache_write=18.75),
|
|
24
|
+
"sonnet": Price(input=3.0, output=15.0, cache_read=0.3, cache_write=3.75),
|
|
25
|
+
"haiku": Price(input=1.0, output=5.0, cache_read=0.1, cache_write=1.25),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Usage:
|
|
31
|
+
#: Assistant messages (model calls) so far.
|
|
32
|
+
turns: int = 0
|
|
33
|
+
cost_usd: float = 0.0
|
|
34
|
+
#: The part of `cost_usd` spent after a human steered the session.
|
|
35
|
+
steered_cost_usd: float = 0.0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def agent_cost_usd(self) -> float:
|
|
39
|
+
return self.cost_usd - self.steered_cost_usd
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _price(model: str, prices: dict[str, Price]) -> Price:
|
|
43
|
+
for family, price in prices.items():
|
|
44
|
+
if family in model:
|
|
45
|
+
return price
|
|
46
|
+
# Unknown model: assume the dearest family so a budget is never silently under-counted.
|
|
47
|
+
return max(prices.values(), key=lambda p: p.output)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _epoch(stamp: Any) -> float | None:
|
|
51
|
+
if not isinstance(stamp, str):
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return datetime.fromisoformat(stamp.replace("Z", "+00:00")).timestamp()
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _loads(raw: str) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(raw)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _obj(value: Any) -> dict[str, Any] | None:
|
|
67
|
+
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tokens(usage: dict[str, Any], key: str) -> int:
|
|
71
|
+
value = usage.get(key)
|
|
72
|
+
return value if isinstance(value, int) else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_usage(
|
|
76
|
+
path: str | None,
|
|
77
|
+
prices: dict[str, Price] | None = None,
|
|
78
|
+
*,
|
|
79
|
+
steered_since: float | None = None,
|
|
80
|
+
) -> Usage:
|
|
81
|
+
"""Estimates turns and spend from a Claude Code session transcript (JSONL).
|
|
82
|
+
|
|
83
|
+
A message is logged once per content block, so messages are deduplicated by id. Spend after
|
|
84
|
+
`steered_since` (epoch seconds) is reported separately.
|
|
85
|
+
"""
|
|
86
|
+
table = prices if prices is not None else DEFAULT_PRICES
|
|
87
|
+
usage = Usage()
|
|
88
|
+
if path is None:
|
|
89
|
+
return usage
|
|
90
|
+
try:
|
|
91
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
92
|
+
except OSError:
|
|
93
|
+
return usage
|
|
94
|
+
seen: set[str] = set()
|
|
95
|
+
for raw in text.splitlines():
|
|
96
|
+
entry = _obj(_loads(raw))
|
|
97
|
+
if entry is None or entry.get("type") != "assistant":
|
|
98
|
+
continue
|
|
99
|
+
message = _obj(entry.get("message"))
|
|
100
|
+
if message is None:
|
|
101
|
+
continue
|
|
102
|
+
message_id = message.get("id")
|
|
103
|
+
if isinstance(message_id, str):
|
|
104
|
+
if message_id in seen:
|
|
105
|
+
continue
|
|
106
|
+
seen.add(message_id)
|
|
107
|
+
usage.turns += 1
|
|
108
|
+
counts = _obj(message.get("usage"))
|
|
109
|
+
if counts is None:
|
|
110
|
+
continue
|
|
111
|
+
model = message.get("model")
|
|
112
|
+
price = _price(model if isinstance(model, str) else "", table)
|
|
113
|
+
cost = (
|
|
114
|
+
_tokens(counts, "input_tokens") * price.input
|
|
115
|
+
+ _tokens(counts, "output_tokens") * price.output
|
|
116
|
+
+ _tokens(counts, "cache_read_input_tokens") * price.cache_read
|
|
117
|
+
+ _tokens(counts, "cache_creation_input_tokens") * price.cache_write
|
|
118
|
+
) / 1_000_000
|
|
119
|
+
usage.cost_usd += cost
|
|
120
|
+
when = _epoch(entry.get("timestamp"))
|
|
121
|
+
if steered_since is not None and when is not None and when >= steered_since:
|
|
122
|
+
usage.steered_cost_usd += cost
|
|
123
|
+
return usage
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The `whiletrue` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from whiletrue.cli.app import entrypoint, main
|
|
4
|
+
from whiletrue.cli.common import EXIT, Io
|
|
5
|
+
from whiletrue.cli.registry import RegistryOptions, create_default_registry, load_plugins
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EXIT",
|
|
9
|
+
"Io",
|
|
10
|
+
"RegistryOptions",
|
|
11
|
+
"create_default_registry",
|
|
12
|
+
"entrypoint",
|
|
13
|
+
"load_plugins",
|
|
14
|
+
"main",
|
|
15
|
+
]
|