tightloop 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.
- loop/__init__.py +40 -0
- loop/approval/__init__.py +87 -0
- loop/blueprints/__init__.py +3 -0
- loop/blueprints/testfix.py +117 -0
- loop/context/__init__.py +144 -0
- loop/core/__init__.py +0 -0
- loop/core/engine.py +515 -0
- loop/core/result.py +64 -0
- loop/core/state.py +143 -0
- loop/exit/__init__.py +60 -0
- loop/llm/__init__.py +70 -0
- loop/llm/anthropic.py +45 -0
- loop/llm/openai.py +55 -0
- loop/policy/__init__.py +96 -0
- loop/pricing.py +47 -0
- loop/progress/__init__.py +72 -0
- loop/tools/__init__.py +220 -0
- loop/trace/__init__.py +81 -0
- tightloop-0.1.0.dist-info/METADATA +439 -0
- tightloop-0.1.0.dist-info/RECORD +21 -0
- tightloop-0.1.0.dist-info/WHEEL +4 -0
loop/tools/__init__.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Tools: type-hint schema derivation, validated execution, enforced timeouts.
|
|
2
|
+
|
|
3
|
+
- Unsupported type hints fail at REGISTRATION, never silently.
|
|
4
|
+
- Schemas are derived once and frozen for the loop lifetime (hash-checked on resume).
|
|
5
|
+
- Thread runner: timeout marks the result `aborted` (threads cannot be force-killed —
|
|
6
|
+
use run_command for long/untrusted operations; it escalates SIGTERM → SIGKILL).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextvars
|
|
11
|
+
import enum
|
|
12
|
+
import hashlib
|
|
13
|
+
import inspect
|
|
14
|
+
import json
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
import typing
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from concurrent.futures import TimeoutError as FutureTimeout
|
|
21
|
+
from typing import Any, Callable, Literal, Union
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ValidationError, create_model
|
|
24
|
+
|
|
25
|
+
from ..core.state import digest, excerpt
|
|
26
|
+
|
|
27
|
+
_SUPPORTED_BASES = (str, int, float, bool)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsupportedTypeError(TypeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolValidationError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_supported(annotation: Any, tool_name: str, param: str) -> None:
|
|
39
|
+
if annotation in _SUPPORTED_BASES or annotation is type(None):
|
|
40
|
+
return
|
|
41
|
+
if isinstance(annotation, type) and issubclass(annotation, (enum.Enum, BaseModel)):
|
|
42
|
+
return
|
|
43
|
+
origin = typing.get_origin(annotation)
|
|
44
|
+
if origin in (list, dict, Union, Literal):
|
|
45
|
+
for arg in typing.get_args(annotation):
|
|
46
|
+
if origin is Literal:
|
|
47
|
+
continue # literal values, not types
|
|
48
|
+
_check_supported(arg, tool_name, param)
|
|
49
|
+
return
|
|
50
|
+
if annotation in (list, dict):
|
|
51
|
+
return
|
|
52
|
+
raise UnsupportedTypeError(
|
|
53
|
+
f"tool {tool_name!r} parameter {param!r}: unsupported type hint {annotation!r}. "
|
|
54
|
+
"Supported: str, int, float, bool, list, dict, Optional, Literal, Enum, pydantic models."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _default_normalize(args: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
+
"""Fingerprint normalizer: long strings (file contents) are hashed, short values
|
|
60
|
+
(paths, names) kept exact — so near-identical edits still fingerprint distinctly."""
|
|
61
|
+
out: dict[str, Any] = {}
|
|
62
|
+
for k, v in args.items():
|
|
63
|
+
if isinstance(v, str) and len(v) > 200:
|
|
64
|
+
out[k] = f"sha:{digest(v)}"
|
|
65
|
+
else:
|
|
66
|
+
out[k] = v
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Tool:
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
fn: Callable[..., Any],
|
|
74
|
+
name: str | None = None,
|
|
75
|
+
description: str | None = None,
|
|
76
|
+
timeout_s: float = 60.0,
|
|
77
|
+
cleanup: Callable[[], None] | None = None,
|
|
78
|
+
normalizer: Callable[[dict], dict] | None = None,
|
|
79
|
+
):
|
|
80
|
+
self.fn = fn
|
|
81
|
+
self.name = name or fn.__name__
|
|
82
|
+
self.description = description or (fn.__doc__ or "").strip()
|
|
83
|
+
self.timeout_s = timeout_s
|
|
84
|
+
self.cleanup = cleanup
|
|
85
|
+
self.normalizer = normalizer or _default_normalize
|
|
86
|
+
|
|
87
|
+
sig = inspect.signature(fn)
|
|
88
|
+
# resolve string annotations (PEP 563 / `from __future__ import annotations`)
|
|
89
|
+
hints = typing.get_type_hints(fn)
|
|
90
|
+
fields: dict[str, Any] = {}
|
|
91
|
+
for pname, p in sig.parameters.items():
|
|
92
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
93
|
+
raise UnsupportedTypeError(f"tool {self.name!r}: *args/**kwargs are not supported")
|
|
94
|
+
if pname not in hints:
|
|
95
|
+
raise UnsupportedTypeError(f"tool {self.name!r} parameter {pname!r}: type hint required")
|
|
96
|
+
annotation = hints[pname]
|
|
97
|
+
_check_supported(annotation, self.name, pname)
|
|
98
|
+
default = p.default if p.default is not inspect.Parameter.empty else ...
|
|
99
|
+
fields[pname] = (annotation, default)
|
|
100
|
+
self.params_model = create_model(f"{self.name}_params", **fields)
|
|
101
|
+
self.json_schema = {
|
|
102
|
+
"name": self.name,
|
|
103
|
+
"description": self.description,
|
|
104
|
+
"input_schema": self.params_model.model_json_schema(),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def validate(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
try:
|
|
109
|
+
model = self.params_model.model_validate(args)
|
|
110
|
+
except ValidationError as e:
|
|
111
|
+
raise ToolValidationError(f"invalid arguments for tool {self.name!r}: {e}") from e
|
|
112
|
+
return {k: getattr(model, k) for k in self.params_model.model_fields}
|
|
113
|
+
|
|
114
|
+
def fingerprint(self, args: dict[str, Any]) -> str:
|
|
115
|
+
normalized = self.normalizer(args)
|
|
116
|
+
return digest(self.name + json.dumps(normalized, sort_keys=True, default=str))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def tool(
|
|
120
|
+
fn: Callable | None = None,
|
|
121
|
+
*,
|
|
122
|
+
name: str | None = None,
|
|
123
|
+
description: str | None = None,
|
|
124
|
+
timeout_s: float = 60.0,
|
|
125
|
+
cleanup: Callable[[], None] | None = None,
|
|
126
|
+
normalizer: Callable[[dict], dict] | None = None,
|
|
127
|
+
) -> Tool | Callable[[Callable], Tool]:
|
|
128
|
+
def wrap(f: Callable) -> Tool:
|
|
129
|
+
return Tool(f, name=name, description=description, timeout_s=timeout_s,
|
|
130
|
+
cleanup=cleanup, normalizer=normalizer)
|
|
131
|
+
|
|
132
|
+
return wrap(fn) if fn is not None else wrap
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ToolResult(BaseModel):
|
|
136
|
+
status: Literal["ok", "error", "aborted"]
|
|
137
|
+
output: str
|
|
138
|
+
duration_s: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ToolRegistry:
|
|
142
|
+
"""Frozen at construction; schema_hash detects drift on resume."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, tools: list[Tool | Callable]):
|
|
145
|
+
self.tools: dict[str, Tool] = {}
|
|
146
|
+
for t in tools:
|
|
147
|
+
t = t if isinstance(t, Tool) else Tool(t)
|
|
148
|
+
if t.name in self.tools:
|
|
149
|
+
raise ValueError(f"duplicate tool name {t.name!r}")
|
|
150
|
+
self.tools[t.name] = t
|
|
151
|
+
canonical = json.dumps([t.json_schema for t in self.tools.values()], sort_keys=True)
|
|
152
|
+
self.schema_hash = hashlib.sha256(canonical.encode()).hexdigest()[:16]
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
156
|
+
return [t.json_schema for t in self.tools.values()]
|
|
157
|
+
|
|
158
|
+
def get(self, name: str) -> Tool | None:
|
|
159
|
+
return self.tools.get(name)
|
|
160
|
+
|
|
161
|
+
def execute(self, name: str, args: dict[str, Any]) -> ToolResult:
|
|
162
|
+
t = self.tools[name]
|
|
163
|
+
start = time.monotonic()
|
|
164
|
+
# copy_context so the engine's re-entrancy guard propagates into the worker thread
|
|
165
|
+
ctx = contextvars.copy_context()
|
|
166
|
+
executor = ThreadPoolExecutor(max_workers=1)
|
|
167
|
+
try:
|
|
168
|
+
future = executor.submit(ctx.run, t.fn, **args)
|
|
169
|
+
try:
|
|
170
|
+
out = future.result(timeout=t.timeout_s)
|
|
171
|
+
return ToolResult(status="ok", output=excerpt(str(out)),
|
|
172
|
+
duration_s=time.monotonic() - start)
|
|
173
|
+
except FutureTimeout:
|
|
174
|
+
if t.cleanup:
|
|
175
|
+
try:
|
|
176
|
+
t.cleanup()
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
return ToolResult(
|
|
180
|
+
status="aborted",
|
|
181
|
+
output=f"aborted: exceeded timeout of {t.timeout_s}s",
|
|
182
|
+
duration_s=time.monotonic() - start,
|
|
183
|
+
)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return ToolResult(status="error", output=excerpt(f"{type(e).__name__}: {e}"),
|
|
186
|
+
duration_s=time.monotonic() - start)
|
|
187
|
+
finally:
|
|
188
|
+
executor.shutdown(wait=False)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class CommandResult(BaseModel):
|
|
192
|
+
code: int
|
|
193
|
+
stdout: str
|
|
194
|
+
stderr: str
|
|
195
|
+
timed_out: bool = False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_command(cmd: list[str] | str, timeout_s: float = 120.0, cwd: str | None = None) -> CommandResult:
|
|
199
|
+
"""Subprocess runner with enforced SIGTERM → SIGKILL escalation."""
|
|
200
|
+
proc = subprocess.Popen(
|
|
201
|
+
cmd,
|
|
202
|
+
shell=isinstance(cmd, str),
|
|
203
|
+
cwd=cwd,
|
|
204
|
+
stdout=subprocess.PIPE,
|
|
205
|
+
stderr=subprocess.PIPE,
|
|
206
|
+
text=True,
|
|
207
|
+
start_new_session=True,
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
stdout, stderr = proc.communicate(timeout=timeout_s)
|
|
211
|
+
return CommandResult(code=proc.returncode, stdout=stdout, stderr=stderr)
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
proc.send_signal(signal.SIGTERM)
|
|
214
|
+
try:
|
|
215
|
+
stdout, stderr = proc.communicate(timeout=5)
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
proc.kill()
|
|
218
|
+
stdout, stderr = proc.communicate()
|
|
219
|
+
return CommandResult(code=proc.returncode if proc.returncode is not None else -9,
|
|
220
|
+
stdout=stdout or "", stderr=stderr or "", timed_out=True)
|
loop/trace/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Live-streamed structured trace + explain()."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from ..core.result import LoopResult
|
|
12
|
+
from ..core.state import State
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TraceSink:
|
|
16
|
+
"""Appends each event to a JSONL file (live) and forwards to an optional callback."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: str | Path | None = None, on_event: Callable[[dict], None] | None = None):
|
|
19
|
+
self.path = Path(path) if path else None
|
|
20
|
+
self.on_event = on_event
|
|
21
|
+
|
|
22
|
+
def emit(self, kind: str, **data: Any) -> None:
|
|
23
|
+
event = {"ts": time.time(), "kind": kind, **data}
|
|
24
|
+
if self.path:
|
|
25
|
+
with self.path.open("a") as f:
|
|
26
|
+
f.write(json.dumps(event, default=str) + "\n")
|
|
27
|
+
if self.on_event:
|
|
28
|
+
try:
|
|
29
|
+
self.on_event(event)
|
|
30
|
+
except Exception:
|
|
31
|
+
pass # user callback failures never take down the loop
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExplainReport(BaseModel):
|
|
35
|
+
status: str
|
|
36
|
+
reason: str
|
|
37
|
+
signals: dict[str, Any] = Field(default_factory=dict)
|
|
38
|
+
decision_chain: list[str] = Field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def render(self) -> str:
|
|
41
|
+
lines = [f"# Why the loop stopped: {self.status}", "", f"**Reason:** {self.reason}", "", "## Signals"]
|
|
42
|
+
lines += [f"- {k}: {v}" for k, v in self.signals.items()]
|
|
43
|
+
lines += ["", "## Decision chain"]
|
|
44
|
+
lines += [f"{i + 1}. {step}" for i, step in enumerate(self.decision_chain)]
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def explain(state: State, result: LoopResult | None = None) -> ExplainReport:
|
|
49
|
+
"""Structured 'why did it stop' report; callable live or post-hoc."""
|
|
50
|
+
last_metric = next((it.metric for it in reversed(state.iterations) if it.metric), None)
|
|
51
|
+
signals: dict[str, Any] = {
|
|
52
|
+
"iterations": len(state.iterations),
|
|
53
|
+
"input_tokens": state.metrics.input_tokens,
|
|
54
|
+
"output_tokens": state.metrics.output_tokens,
|
|
55
|
+
"llm_calls": state.metrics.llm_calls,
|
|
56
|
+
"elapsed_s": round(state.metrics.elapsed_s, 2),
|
|
57
|
+
"cost_usd_estimate": state.metrics.cost_usd,
|
|
58
|
+
"no_progress_streak": state.no_progress_streak,
|
|
59
|
+
"plan_invalid_streak": state.plan_invalid_streak,
|
|
60
|
+
"last_metric": last_metric.model_dump() if last_metric else None,
|
|
61
|
+
"failed_approaches": len(state.failed_approaches),
|
|
62
|
+
}
|
|
63
|
+
chain: list[str] = []
|
|
64
|
+
for it in state.iterations:
|
|
65
|
+
bits = [f"iteration {it.index}:"]
|
|
66
|
+
if it.plan_invalid:
|
|
67
|
+
bits.append("plan failed validation;")
|
|
68
|
+
for a in it.actions:
|
|
69
|
+
bits.append(f"{a.tool}[{a.status}]")
|
|
70
|
+
if it.metric:
|
|
71
|
+
bits.append(f"metric={it.metric.value}")
|
|
72
|
+
if it.metric.regression:
|
|
73
|
+
bits.append("(REGRESSION)")
|
|
74
|
+
if it.repetition:
|
|
75
|
+
bits.append("(repetition flagged)")
|
|
76
|
+
chain.append(" ".join(bits))
|
|
77
|
+
status = result.status.value if result else "RUNNING"
|
|
78
|
+
reason = result.reason if result else "loop has not exited"
|
|
79
|
+
if result:
|
|
80
|
+
chain.append(f"exit: {status} — {reason}")
|
|
81
|
+
return ExplainReport(status=status, reason=reason, signals=signals, decision_chain=chain)
|