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 +177 -0
- looplet/__main__.py +143 -0
- looplet/approval.py +135 -0
- looplet/backends.py +536 -0
- looplet/budget.py +235 -0
- looplet/cache.py +211 -0
- looplet/checkpoint.py +286 -0
- looplet/compact.py +598 -0
- looplet/context.py +386 -0
- looplet/conversation.py +492 -0
- looplet/evals.py +768 -0
- looplet/events.py +100 -0
- looplet/examples/__init__.py +1 -0
- looplet/examples/coding_agent.py +747 -0
- looplet/examples/hello_world.py +83 -0
- looplet/examples/ollama_hello.py +63 -0
- looplet/flags.py +98 -0
- looplet/history.py +160 -0
- looplet/hook_decision.py +233 -0
- looplet/loop.py +1850 -0
- looplet/mcp.py +253 -0
- looplet/memory.py +99 -0
- looplet/parse.py +171 -0
- looplet/permissions.py +260 -0
- looplet/presets.py +478 -0
- looplet/prompts.py +245 -0
- looplet/provenance.py +1016 -0
- looplet/py.typed +0 -0
- looplet/recovery.py +289 -0
- looplet/recovery_strategies.py +86 -0
- looplet/router.py +380 -0
- looplet/scaffolding.py +813 -0
- looplet/session.py +324 -0
- looplet/skills.py +84 -0
- looplet/streaming.py +446 -0
- looplet/subagent.py +233 -0
- looplet/telemetry.py +429 -0
- looplet/testing.py +126 -0
- looplet/tools.py +465 -0
- looplet/types.py +532 -0
- looplet/validation.py +297 -0
- looplet-0.1.7.dist-info/METADATA +196 -0
- looplet-0.1.7.dist-info/RECORD +45 -0
- looplet-0.1.7.dist-info/WHEEL +4 -0
- looplet-0.1.7.dist-info/licenses/LICENSE +201 -0
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
|
+
|