monkeybot 2.1.1__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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Tool inspectors: ``run_command`` policy (allowlists + optional deny-regex YAML)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal, Protocol
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from monkeybot.core.context import TurnContext
|
|
13
|
+
from monkeybot.core.types.interfaces import MonkeybotError
|
|
14
|
+
from monkeybot.core.tools.terminal import ALLOWED_COMMANDS, ALLOWED_PATHS
|
|
15
|
+
|
|
16
|
+
# Default deny-regex lines when YAML omits ``deny_patterns``. Blocks package installs
|
|
17
|
+
# via common verbs (pip, uv, npm, apt, brew, etc.) while leaving script execution allowed.
|
|
18
|
+
DEFAULT_DENY_PATTERNS: tuple[str, ...] = (
|
|
19
|
+
r"pip3?\s+install",
|
|
20
|
+
r"python3?\s+-m\s+pip\s+install",
|
|
21
|
+
r"(^|\s)uv\s+(add|remove|sync|pip\s+install|pip\s+sync)",
|
|
22
|
+
r"(^|\s)poetry\s+(add|install)",
|
|
23
|
+
r"(^|\s)conda\s+install",
|
|
24
|
+
r"(^|\s)(npm|pnpm|yarn)\s+(install|add|i)(\s|$)",
|
|
25
|
+
r"(^|\s)(apt|apt-get)\s+install",
|
|
26
|
+
r"(^|\s)brew\s+install",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommandTierConfigError(MonkeybotError):
|
|
31
|
+
"""Invalid or unloadable run_command policy file."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, path: Path, reason: str) -> None:
|
|
34
|
+
self.path = path
|
|
35
|
+
self.reason = reason
|
|
36
|
+
super().__init__(f"{path}: {reason}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Decision:
|
|
41
|
+
"""Allow, deny, or confirm outcome from a tool inspector.
|
|
42
|
+
|
|
43
|
+
``confirm`` requires an SSE session so the user can approve via tool-confirmations.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
kind: Literal["allow", "deny", "confirm"]
|
|
47
|
+
message: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class InspectorToolCall:
|
|
52
|
+
"""Normalized tool call at the inspection boundary (Story 6 maps provider calls here)."""
|
|
53
|
+
|
|
54
|
+
call_id: str
|
|
55
|
+
name: str
|
|
56
|
+
args: dict[str, object]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ToolInspector(Protocol):
|
|
60
|
+
"""Pre-flight gate for tool execution."""
|
|
61
|
+
|
|
62
|
+
async def check(self, call: InspectorToolCall, ctx: TurnContext) -> Decision:
|
|
63
|
+
"""Return allow or deny before executing the tool."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class CommandTierPolicy:
|
|
69
|
+
"""Parsed ``command_allowlist.yaml``: execution allowlists + optional deny-regex lines."""
|
|
70
|
+
|
|
71
|
+
allowed_commands: tuple[str, ...]
|
|
72
|
+
allowed_path_prefixes: tuple[str, ...]
|
|
73
|
+
deny_patterns: tuple[str, ...]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _optional_nonempty_str_list(path: Path, data: dict[str, Any], key: str) -> list[str] | None:
|
|
77
|
+
if key not in data:
|
|
78
|
+
return None
|
|
79
|
+
val = data[key]
|
|
80
|
+
if not isinstance(val, list) or not val:
|
|
81
|
+
raise CommandTierConfigError(
|
|
82
|
+
path, f"'{key}' must be a non-empty list of strings when present"
|
|
83
|
+
)
|
|
84
|
+
out: list[str] = []
|
|
85
|
+
for i, item in enumerate(val):
|
|
86
|
+
if not isinstance(item, str) or not item.strip():
|
|
87
|
+
raise CommandTierConfigError(
|
|
88
|
+
path, f"'{key}[{i}]' must be a non-empty string"
|
|
89
|
+
)
|
|
90
|
+
out.append(item)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def norm_run_command_line(args: dict[str, object]) -> str | None:
|
|
95
|
+
"""Build one line for deny-regex checks (mirrors ``run_command`` argv shapes)."""
|
|
96
|
+
argv_raw = args.get("argv")
|
|
97
|
+
if isinstance(argv_raw, list) and argv_raw:
|
|
98
|
+
parts = [str(x) for x in argv_raw]
|
|
99
|
+
return shlex.join(parts)
|
|
100
|
+
|
|
101
|
+
cmd = args.get("command")
|
|
102
|
+
if isinstance(cmd, str) and cmd.strip():
|
|
103
|
+
extra = args.get("args")
|
|
104
|
+
if extra is None:
|
|
105
|
+
extra = args.get("arguments")
|
|
106
|
+
if isinstance(extra, list) and extra:
|
|
107
|
+
parts = [cmd.strip()] + [str(x) for x in extra]
|
|
108
|
+
return shlex.join(parts)
|
|
109
|
+
parts = shlex.split(cmd.strip(), posix=True)
|
|
110
|
+
if len(parts) > 1:
|
|
111
|
+
return shlex.join(parts)
|
|
112
|
+
return " ".join(cmd.strip().split())
|
|
113
|
+
|
|
114
|
+
shell = args.get("shell") or args.get("script")
|
|
115
|
+
if isinstance(shell, str) and shell.strip():
|
|
116
|
+
return " ".join(shell.strip().split())
|
|
117
|
+
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_command_tier_policy(path: Path) -> CommandTierPolicy:
|
|
122
|
+
"""Load ``run_command`` allowlists and optional ``deny_patterns`` from policy YAML."""
|
|
123
|
+
raw_bytes = path.read_bytes()
|
|
124
|
+
try:
|
|
125
|
+
data = yaml.safe_load(raw_bytes)
|
|
126
|
+
except yaml.YAMLError as e:
|
|
127
|
+
raise CommandTierConfigError(path, f"invalid YAML: {e}") from e
|
|
128
|
+
|
|
129
|
+
if data is None:
|
|
130
|
+
raise CommandTierConfigError(path, "file is empty")
|
|
131
|
+
if not isinstance(data, dict):
|
|
132
|
+
raise CommandTierConfigError(path, "root must be a mapping")
|
|
133
|
+
|
|
134
|
+
for legacy_key in ("tier_order", "default"):
|
|
135
|
+
if legacy_key in data:
|
|
136
|
+
raise CommandTierConfigError(
|
|
137
|
+
path,
|
|
138
|
+
f"obsolete key {legacy_key!r}: tier-based policy was removed; use "
|
|
139
|
+
"'allowed_commands', 'allowed_path_prefixes', and optional 'deny_patterns'",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
ac_raw = _optional_nonempty_str_list(path, data, "allowed_commands")
|
|
143
|
+
ap_raw = _optional_nonempty_str_list(path, data, "allowed_path_prefixes")
|
|
144
|
+
|
|
145
|
+
deny_raw = data.get("deny_patterns")
|
|
146
|
+
if deny_raw is None:
|
|
147
|
+
deny_patterns = DEFAULT_DENY_PATTERNS
|
|
148
|
+
elif isinstance(deny_raw, list):
|
|
149
|
+
for i, item in enumerate(deny_raw):
|
|
150
|
+
if not isinstance(item, str) or not item.strip():
|
|
151
|
+
raise CommandTierConfigError(
|
|
152
|
+
path, f"'deny_patterns[{i}]' must be a non-empty string"
|
|
153
|
+
)
|
|
154
|
+
deny_patterns = tuple(str(x) for x in deny_raw)
|
|
155
|
+
else:
|
|
156
|
+
raise CommandTierConfigError(
|
|
157
|
+
path, "'deny_patterns' must be a list of strings when present"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
allowed_commands = tuple(ac_raw) if ac_raw is not None else tuple(ALLOWED_COMMANDS)
|
|
161
|
+
allowed_path_prefixes = tuple(ap_raw) if ap_raw is not None else tuple(ALLOWED_PATHS)
|
|
162
|
+
|
|
163
|
+
return CommandTierPolicy(
|
|
164
|
+
allowed_commands=allowed_commands,
|
|
165
|
+
allowed_path_prefixes=allowed_path_prefixes,
|
|
166
|
+
deny_patterns=deny_patterns,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CommandTierInspector:
|
|
171
|
+
"""YAML policy: optional deny-regex preflight; execution allowlists are on the policy object."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, tier_config_path: Path) -> None:
|
|
174
|
+
self._path = tier_config_path
|
|
175
|
+
self._policy = load_command_tier_policy(tier_config_path)
|
|
176
|
+
compiled: list[re.Pattern[str]] = []
|
|
177
|
+
for i, pat in enumerate(self._policy.deny_patterns):
|
|
178
|
+
try:
|
|
179
|
+
compiled.append(re.compile(pat, flags=re.IGNORECASE))
|
|
180
|
+
except re.error as e:
|
|
181
|
+
raise CommandTierConfigError(
|
|
182
|
+
tier_config_path, f"deny_patterns[{i}]: invalid regex: {e}"
|
|
183
|
+
) from e
|
|
184
|
+
self._deny_regexes = tuple(compiled)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def allowed_commands(self) -> tuple[str, ...]:
|
|
188
|
+
return self._policy.allowed_commands
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def allowed_path_prefixes(self) -> tuple[str, ...]:
|
|
192
|
+
return self._policy.allowed_path_prefixes
|
|
193
|
+
|
|
194
|
+
async def check(self, call: InspectorToolCall, ctx: TurnContext) -> Decision:
|
|
195
|
+
"""Deny if any ``deny_patterns`` matches the normalized invocation; else allow."""
|
|
196
|
+
del ctx
|
|
197
|
+
if call.name != "run_command":
|
|
198
|
+
return Decision(kind="allow")
|
|
199
|
+
|
|
200
|
+
cmd_norm = norm_run_command_line(call.args)
|
|
201
|
+
if not cmd_norm:
|
|
202
|
+
return Decision(
|
|
203
|
+
kind="deny",
|
|
204
|
+
message="run_command policy check needs argv, command, or shell/script",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
for rx in self._deny_regexes:
|
|
208
|
+
if rx.search(cmd_norm):
|
|
209
|
+
return Decision(kind="deny", message="denied by run_command deny_patterns policy")
|
|
210
|
+
|
|
211
|
+
return Decision(kind="allow")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class RulesInspector:
|
|
215
|
+
"""Blocks tool calls whose serialized arguments contain any denied substring."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, denied_patterns: list[str]) -> None:
|
|
218
|
+
self.denied_patterns = denied_patterns
|
|
219
|
+
|
|
220
|
+
async def check(self, call: InspectorToolCall, ctx: TurnContext) -> Decision:
|
|
221
|
+
del ctx
|
|
222
|
+
args_str = str(call.args)
|
|
223
|
+
for pattern in self.denied_patterns:
|
|
224
|
+
if pattern in args_str:
|
|
225
|
+
return Decision(kind="deny", message=f"Blocked by rules: {pattern}")
|
|
226
|
+
return Decision(kind="allow")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Inspector that requires user confirmation before starting a scheduled loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot.core.context import TurnContext
|
|
6
|
+
from monkeybot.core.tools.inspector import Decision, InspectorToolCall, ToolInspector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LoopStartInspector:
|
|
10
|
+
"""Return ``confirm`` for ``start_loop`` so the user approves the plan before it runs."""
|
|
11
|
+
|
|
12
|
+
async def check(self, call: InspectorToolCall, ctx: TurnContext) -> Decision:
|
|
13
|
+
if call.name != "start_loop":
|
|
14
|
+
return Decision(kind="allow")
|
|
15
|
+
prompt = call.args.get("prompt")
|
|
16
|
+
interval = call.args.get("interval", "?")
|
|
17
|
+
loop_id = call.args.get("loop_id") or "(auto)"
|
|
18
|
+
session_id = call.args.get("session_id") or "loop-main"
|
|
19
|
+
max_ticks = call.args.get("max_ticks")
|
|
20
|
+
max_runtime = call.args.get("max_runtime")
|
|
21
|
+
unbounded = call.args.get("unbounded", False) is True
|
|
22
|
+
preview = str(prompt).strip() if isinstance(prompt, str) else ""
|
|
23
|
+
if len(preview) > 400:
|
|
24
|
+
preview = preview[:400] + "…"
|
|
25
|
+
guards: list[str] = []
|
|
26
|
+
if max_ticks is not None:
|
|
27
|
+
guards.append(f"max_ticks={max_ticks}")
|
|
28
|
+
if max_runtime is not None:
|
|
29
|
+
guards.append(f"max_runtime={max_runtime}")
|
|
30
|
+
if unbounded:
|
|
31
|
+
guard_text = "UNBOUNDED (no max_ticks/max_runtime)"
|
|
32
|
+
elif guards:
|
|
33
|
+
guard_text = ", ".join(guards)
|
|
34
|
+
else:
|
|
35
|
+
guard_text = "MISSING — requires max_ticks, max_runtime, or unbounded=true"
|
|
36
|
+
message = (
|
|
37
|
+
f"Start scheduled loop?\n"
|
|
38
|
+
f"- loop_id: {loop_id}\n"
|
|
39
|
+
f"- session: {session_id}\n"
|
|
40
|
+
f"- interval: {interval}\n"
|
|
41
|
+
f"- guards: {guard_text}\n\n"
|
|
42
|
+
f"Plan:\n{preview or '(empty prompt)'}"
|
|
43
|
+
)
|
|
44
|
+
del ctx
|
|
45
|
+
return Decision(kind="confirm", message=message)
|