looplet 0.1.7__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.
looplet/__init__.py ADDED
@@ -0,0 +1,177 @@
1
+ """looplet — composable tool-calling LLM agent harness.
2
+
3
+ A minimal, composable framework for building tool-calling LLM agent loops.
4
+
5
+ Only symbols listed in ``__all__`` are imported here. For everything
6
+ else, import from the relevant submodule::
7
+
8
+ from looplet.backends import OpenAIStreamingBackend, AsyncOpenAIBackend
9
+ from looplet.provenance import ProvenanceSink, replay_loop
10
+ from looplet.cache import CacheBreakDetector, compute_breakpoints
11
+ """
12
+
13
+ __version__ = "0.1.6"
14
+
15
+ # ── Public re-exports (one import per submodule, alphabetical) ──────────
16
+ # ruff: noqa: F401 — __init__.py intentionally re-exports for `from looplet import X`
17
+
18
+ from looplet.approval import ApprovalHook
19
+ from looplet.backends import AnthropicBackend, OpenAIBackend
20
+ from looplet.budget import ContextBudget, ThresholdCompactHook
21
+ from looplet.cache import CachePolicy
22
+ from looplet.checkpoint import FileCheckpointStore
23
+ from looplet.compact import (
24
+ CompactOutcome,
25
+ CompactService,
26
+ PruneToolResults,
27
+ SummarizeCompact,
28
+ TruncateCompact,
29
+ compact_chain,
30
+ run_compact,
31
+ )
32
+ from looplet.conversation import Conversation, Message
33
+ from looplet.evals import (
34
+ EvalContext,
35
+ EvalHook,
36
+ EvalResult,
37
+ eval_cli,
38
+ eval_discover,
39
+ eval_mark,
40
+ eval_run,
41
+ eval_run_batch,
42
+ )
43
+ from looplet.events import EventPayload, LifecycleEvent
44
+ from looplet.hook_decision import (
45
+ Allow,
46
+ Block,
47
+ Continue,
48
+ Deny,
49
+ HookDecision,
50
+ InjectContext,
51
+ Stop,
52
+ )
53
+ from looplet.loop import DomainAdapter, LoopConfig, LoopHook, composable_loop, emit_event
54
+ from looplet.mcp import MCPToolAdapter
55
+ from looplet.memory import CallableMemorySource, StaticMemorySource
56
+ from looplet.permissions import (
57
+ PermissionDecision,
58
+ PermissionEngine,
59
+ PermissionHook,
60
+ PermissionRule,
61
+ )
62
+ from looplet.presets import (
63
+ AgentPreset,
64
+ coding_agent_preset,
65
+ minimal_preset,
66
+ research_agent_preset,
67
+ )
68
+ from looplet.prompts import preview_prompt
69
+ from looplet.provenance import ProvenanceSink, TrajectoryRecorder, replay_loop
70
+ from looplet.session import SessionLog
71
+ from looplet.skills import Skill
72
+ from looplet.streaming import StreamingHook
73
+ from looplet.subagent import run_sub_loop
74
+ from looplet.telemetry import MetricsCollector, MetricsHook, Tracer, TracingHook
75
+ from looplet.testing import AsyncMockLLMBackend, MockLLMBackend
76
+ from looplet.tools import BaseToolRegistry, ToolSpec
77
+ from looplet.types import (
78
+ CancelToken,
79
+ DefaultState,
80
+ ErrorKind,
81
+ LLMBackend,
82
+ NativeToolBackend,
83
+ Step,
84
+ ToolCall,
85
+ ToolContext,
86
+ ToolError,
87
+ ToolResult,
88
+ )
89
+
90
+ __all__ = [
91
+ # ── ESSENTIALS (what you need for your first agent) ──────────
92
+ "__version__",
93
+ "composable_loop",
94
+ "LoopConfig",
95
+ "LoopHook",
96
+ "Step",
97
+ "ToolCall",
98
+ "ToolResult",
99
+ "ToolSpec",
100
+ "BaseToolRegistry",
101
+ "Skill",
102
+ "DefaultState",
103
+ "LLMBackend",
104
+ "HookDecision",
105
+ "Allow",
106
+ "Block",
107
+ "Continue",
108
+ "Deny",
109
+ "Stop",
110
+ "InjectContext",
111
+ "preview_prompt",
112
+ # ── BACKENDS ─────────────────────────────────────────────────
113
+ "OpenAIBackend",
114
+ "AnthropicBackend",
115
+ "NativeToolBackend",
116
+ "MCPToolAdapter",
117
+ # ── CONTEXT MANAGEMENT ──────────────────────────────────────
118
+ "CompactService",
119
+ "CompactOutcome",
120
+ "TruncateCompact",
121
+ "SummarizeCompact",
122
+ "PruneToolResults",
123
+ "compact_chain",
124
+ "run_compact",
125
+ "ContextBudget",
126
+ "ThresholdCompactHook",
127
+ "CachePolicy",
128
+ "StaticMemorySource",
129
+ "CallableMemorySource",
130
+ # ── APPROVAL / PERMISSIONS ──────────────────────────────────
131
+ "ApprovalHook",
132
+ "PermissionEngine",
133
+ "PermissionHook",
134
+ "PermissionRule",
135
+ "PermissionDecision",
136
+ # ── CHECKPOINTS ─────────────────────────────────────────────
137
+ "FileCheckpointStore",
138
+ # ── OBSERVABILITY ───────────────────────────────────────────
139
+ "TrajectoryRecorder",
140
+ "ProvenanceSink",
141
+ "replay_loop",
142
+ "StreamingHook",
143
+ "Tracer",
144
+ "TracingHook",
145
+ "MetricsCollector",
146
+ "MetricsHook",
147
+ # ── EVALS ────────────────────────────────────────────────────
148
+ "EvalHook",
149
+ "EvalContext",
150
+ "EvalResult",
151
+ "eval_discover",
152
+ "eval_run",
153
+ "eval_run_batch",
154
+ "eval_mark",
155
+ "eval_cli",
156
+ # ── TESTING ─────────────────────────────────────────────────
157
+ "MockLLMBackend",
158
+ "AsyncMockLLMBackend",
159
+ # ── ADVANCED (power users import from submodules directly) ──
160
+ "DomainAdapter",
161
+ "emit_event",
162
+ "LifecycleEvent",
163
+ "EventPayload",
164
+ "CancelToken",
165
+ "ToolContext",
166
+ "ToolError",
167
+ "ErrorKind",
168
+ "SessionLog",
169
+ "Conversation",
170
+ "Message",
171
+ "run_sub_loop",
172
+ # ── PRESETS (one-liner agent setup) ─────────────────────────
173
+ "AgentPreset",
174
+ "coding_agent_preset",
175
+ "research_agent_preset",
176
+ "minimal_preset",
177
+ ]
looplet/__main__.py ADDED
@@ -0,0 +1,143 @@
1
+ """``python -m looplet`` — CLI entry point.
2
+
3
+ Subcommands:
4
+ show <trace-dir> One-page summary of a captured trace directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def _fmt_ms(ms: float | int | None) -> str:
17
+ if ms is None:
18
+ return " - "
19
+ return f"{int(ms):>5}ms"
20
+
21
+
22
+ def _render_show(trace_dir: Path) -> int:
23
+ if not trace_dir.exists():
24
+ print(f"error: {trace_dir} does not exist", file=sys.stderr)
25
+ return 1
26
+
27
+ # ── trajectory.json (optional; short-circuit if missing) ─────
28
+ traj_path = trace_dir / "trajectory.json"
29
+ manifest_path = trace_dir / "manifest.jsonl"
30
+ traj: dict[str, Any] = {}
31
+ if traj_path.exists():
32
+ try:
33
+ traj = json.loads(traj_path.read_text(encoding="utf-8"))
34
+ except Exception as exc:
35
+ print(f"error: could not parse {traj_path}: {exc}", file=sys.stderr)
36
+ return 1
37
+
38
+ # ── manifest.jsonl (optional) ────────────────────────────────
39
+ calls: list[dict[str, Any]] = []
40
+ if manifest_path.exists():
41
+ for line in manifest_path.read_text(encoding="utf-8").splitlines():
42
+ line = line.strip()
43
+ if not line:
44
+ continue
45
+ try:
46
+ calls.append(json.loads(line))
47
+ except Exception:
48
+ continue
49
+
50
+ if not traj and not calls:
51
+ print(f"error: {trace_dir} contains no trajectory.json or "
52
+ "manifest.jsonl — not a trace directory", file=sys.stderr)
53
+ return 1
54
+
55
+ # ── Header ──────────────────────────────────────────────────
56
+ run_id = traj.get("run_id") or trace_dir.name
57
+ term = traj.get("termination_reason", "?")
58
+ term_glyph = {"done": "✓", "error": "✗"}.get(term, "·")
59
+ step_count = traj.get("step_count", len(traj.get("steps", [])))
60
+ llm_count = traj.get("llm_call_count", len(calls))
61
+ # Total duration: sum step durations if available, else call durations.
62
+ total_ms = sum(s.get("duration_ms", 0) for s in traj.get("steps", []))
63
+ if total_ms == 0 and calls:
64
+ total_ms = sum(c.get("duration_ms", 0) for c in calls)
65
+
66
+ print(
67
+ f"{run_id} {term_glyph} {term} "
68
+ f"{step_count} steps {llm_count} LLM calls {int(total_ms)}ms"
69
+ )
70
+ print()
71
+
72
+ # ── Steps ───────────────────────────────────────────────────
73
+ for s in traj.get("steps", []):
74
+ num = s.get("step_num", "?")
75
+ tc = s.get("tool_call", {}) or {}
76
+ tr = s.get("tool_result", {}) or {}
77
+ tool = tc.get("tool") or tc.get("action") or "?"
78
+ args = s.get("args_summary") or tr.get("args") or ""
79
+ err = tr.get("error") or s.get("error")
80
+ ok = "✗" if err else "✓"
81
+ data = tr.get("data")
82
+ if err:
83
+ tail = f"ERROR: {str(err)[:40]}"
84
+ elif isinstance(data, list):
85
+ tail = f"{len(data)} items"
86
+ elif isinstance(data, dict):
87
+ tail = f"{tr.get('total_items') or len(data)} keys"
88
+ elif data is None:
89
+ tail = ""
90
+ else:
91
+ snippet = str(data)
92
+ tail = snippet if len(snippet) <= 30 else snippet[:27] + "..."
93
+ dur = _fmt_ms(s.get("duration_ms"))
94
+ linked = s.get("llm_call_indices") or []
95
+ link_str = f"call {linked[0]}" if linked else ""
96
+ print(f"#{num} {ok} {tool}({str(args)[:30]:<30}) "
97
+ f"→ {tail:<20} [{dur}] {link_str}")
98
+
99
+ # ── LLM summary ─────────────────────────────────────────────
100
+ if calls:
101
+ total_prompt = sum(c.get("prompt_chars") or 0 for c in calls)
102
+ total_resp = sum(c.get("response_chars") or 0 for c in calls)
103
+ errors = sum(1 for c in calls if c.get("error"))
104
+ print()
105
+ print(
106
+ f"LLM: {len(calls)} calls, "
107
+ f"{total_prompt:,} in / {total_resp:,} out chars, "
108
+ f"{errors} errors"
109
+ )
110
+
111
+ # ── Failure modes (if present) ──────────────────────────────
112
+ modes = traj.get("failure_modes") or []
113
+ if modes:
114
+ print()
115
+ for fm in modes:
116
+ print(f"!! {fm}")
117
+
118
+ return 0
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = argparse.ArgumentParser(
123
+ prog="python -m looplet",
124
+ description="looplet — inspect captured trace directories",
125
+ )
126
+ sub = parser.add_subparsers(dest="command", required=True)
127
+
128
+ show = sub.add_parser(
129
+ "show",
130
+ help="Show a one-page summary of a captured trace directory",
131
+ )
132
+ show.add_argument("trace_dir", type=Path, help="Path to a trace directory")
133
+
134
+ args = parser.parse_args(argv)
135
+
136
+ if args.command == "show":
137
+ return _render_show(args.trace_dir)
138
+ # Unreachable — argparse rejects unknown commands.
139
+ return 2
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
looplet/approval.py ADDED
@@ -0,0 +1,135 @@
1
+ """Async approval for tool actions that need human sign-off.
2
+
3
+ The pattern:
4
+ 1. A tool detects a high-risk action and sets
5
+ ``{"needs_approval": True, "approval_description": "..."}``
6
+ in its result data.
7
+ 2. ``ApprovalHook.post_dispatch`` detects this and stops the loop
8
+ with ``stop_reason="waiting_for_approval"``.
9
+ 3. The loop checkpoints (if ``checkpoint_dir`` is configured) and
10
+ the generator terminates.
11
+ 4. An external system (webhook, Slack bot, email, human on CLI)
12
+ records the approval.
13
+ 5. The caller restarts the loop — ``checkpoint_dir`` auto-resumes
14
+ from the last step, and the approval is injected into context
15
+ via a ``StaticMemorySource`` or ``pre_prompt`` hook.
16
+
17
+ Usage::
18
+
19
+ from looplet import ApprovalHook, LoopConfig
20
+
21
+ config = LoopConfig(
22
+ checkpoint_dir="./checkpoints", # auto-save + auto-resume
23
+ approval_handler=my_sync_handler, # or None for async-only
24
+ )
25
+ hooks = [ApprovalHook()]
26
+
27
+ For **sync approval** (blocks until human responds)::
28
+
29
+ config = LoopConfig(
30
+ approval_handler=lambda prompt, opts: input(f"{prompt} {opts}: "),
31
+ )
32
+ # No ApprovalHook needed — tools call ctx.approve() inline.
33
+
34
+ For **async approval** (suspend → external approval → resume)::
35
+
36
+ config = LoopConfig(
37
+ checkpoint_dir="./ckpt",
38
+ approval_handler=None, # tools get None → set needs_approval
39
+ )
40
+ hooks = [ApprovalHook()]
41
+ # Loop stops at approval request.
42
+ # Resume: restart the loop, approval injected via memory_sources.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import logging
48
+ from dataclasses import dataclass, field
49
+ from typing import TYPE_CHECKING, Any
50
+
51
+ if TYPE_CHECKING:
52
+ from looplet.session import SessionLog
53
+ from looplet.types import AgentState, ToolCall, ToolResult
54
+
55
+ __all__ = ["ApprovalHook", "ApprovalRequest"]
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ @dataclass(frozen=True)
60
+ class ApprovalRequest:
61
+ """Record of a pending approval request.
62
+
63
+ Stored by :class:`ApprovalHook` when a tool result contains
64
+ ``needs_approval=True``. Callers can inspect ``pending`` after
65
+ the loop stops to build notifications, webhooks, or UIs.
66
+ """
67
+
68
+ step: int
69
+ tool: str
70
+ description: str = ""
71
+ options: list[str] = field(default_factory=lambda: ["approve", "deny"])
72
+
73
+ class ApprovalHook:
74
+ """Hook that stops the loop when a tool requests approval.
75
+
76
+ Detects ``needs_approval=True`` in any tool result's ``data``
77
+ dict and stops the loop so an external system can provide the
78
+ approval before the loop resumes.
79
+
80
+ When the loop stops, ``self.pending`` contains the
81
+ :class:`ApprovalRequest` — use it to send a notification,
82
+ create a webhook, or prompt a human.
83
+
84
+ Combine with ``LoopConfig(checkpoint_dir=...)`` for full
85
+ crash-safe async approval::
86
+
87
+ hook = ApprovalHook()
88
+ config = LoopConfig(checkpoint_dir="./ckpt")
89
+ gen = composable_loop(..., hooks=[hook], config=config)
90
+ for step in gen:
91
+ print(step)
92
+ if hook.pending:
93
+ print(f"Waiting for approval: {hook.pending}")
94
+ # ... external approval happens ...
95
+ # On next run, checkpoint_dir auto-resumes.
96
+ """
97
+
98
+ def __init__(self) -> None:
99
+ self._pending: ApprovalRequest | None = None
100
+
101
+ @property
102
+ def pending(self) -> ApprovalRequest | None:
103
+ """The pending approval request, or None if none is pending."""
104
+ return self._pending
105
+
106
+ def post_dispatch(
107
+ self,
108
+ state: AgentState,
109
+ session_log: SessionLog,
110
+ tool_call: ToolCall,
111
+ tool_result: ToolResult,
112
+ step_num: int,
113
+ ) -> Any:
114
+ """Detect ``needs_approval`` in tool result and stop the loop."""
115
+ from looplet.hook_decision import HookDecision # noqa: PLC0415
116
+
117
+ data = getattr(tool_result, "data", None) or {}
118
+ if not data.get("needs_approval"):
119
+ return None
120
+
121
+ self._pending = ApprovalRequest(
122
+ step=step_num,
123
+ tool=getattr(tool_call, "tool", ""),
124
+ description=data.get("approval_description", ""),
125
+ options=data.get("approval_options", ["approve", "deny"]),
126
+ )
127
+ logger.info(
128
+ "approval_required step=%d tool=%s desc=%s",
129
+ step_num, self._pending.tool, self._pending.description,
130
+ )
131
+ return HookDecision(stop="waiting_for_approval")
132
+
133
+ def should_stop(self, state: AgentState, step_num: int, new_entities: int) -> bool:
134
+ return False
135
+