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/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)