pascal-agent 0.3.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.
- pascal/__init__.py +3 -0
- pascal/__main__.py +880 -0
- pascal/actions.py +1066 -0
- pascal/capability.py +218 -0
- pascal/channels/__init__.py +0 -0
- pascal/channels/telegram.py +108 -0
- pascal/clipboard.py +38 -0
- pascal/config.py +134 -0
- pascal/daemon.py +211 -0
- pascal/desk.py +633 -0
- pascal/effect.py +155 -0
- pascal/eval/__init__.py +1 -0
- pascal/eval/smoke.py +213 -0
- pascal/llm/__init__.py +1 -0
- pascal/llm/anthropic.py +225 -0
- pascal/llm/codex.py +331 -0
- pascal/llm/openai.py +224 -0
- pascal/loop.py +1037 -0
- pascal/mcp.py +206 -0
- pascal/prompt.py +141 -0
- pascal/receipts.py +147 -0
- pascal/sandbox.py +287 -0
- pascal/scheduler.py +243 -0
- pascal/schemas.py +183 -0
- pascal/state.py +790 -0
- pascal/tools.py +672 -0
- pascal/trust.py +150 -0
- pascal/types.py +337 -0
- pascal/uia.py +316 -0
- pascal_agent-0.3.0.dist-info/METADATA +262 -0
- pascal_agent-0.3.0.dist-info/RECORD +33 -0
- pascal_agent-0.3.0.dist-info/WHEEL +4 -0
- pascal_agent-0.3.0.dist-info/entry_points.txt +2 -0
pascal/loop.py
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
"""Pascal main loop — tool-use loop architecture.
|
|
2
|
+
|
|
3
|
+
Claude Code / Codex / ReAct inspired:
|
|
4
|
+
LLM → N tool_calls → execute sequentially → tool_results back to LLM → repeat
|
|
5
|
+
|
|
6
|
+
No JSON serialization for tool_calls — Message.tool_calls is used directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from pascal.actions import execute_action, _auto_postmortem, ActionContext
|
|
19
|
+
from pascal.capability import TrustMap, compute_threshold, infer_domain
|
|
20
|
+
from pascal.desk import Desk
|
|
21
|
+
from pascal.effect import check_allowed, classify_command, effect_to_int, get_max_effect
|
|
22
|
+
from pascal.mcp import MCPManager
|
|
23
|
+
from pascal.prompt import SYSTEM_PROMPT
|
|
24
|
+
from pascal.receipts import Ledger
|
|
25
|
+
from pascal.sandbox import SandboxManager
|
|
26
|
+
from pascal.state import PascalStore, _json_safe
|
|
27
|
+
from pascal.tools import (
|
|
28
|
+
TOOL_EFFECTS,
|
|
29
|
+
cleanup_tools,
|
|
30
|
+
set_workspace,
|
|
31
|
+
get_approval_callback,
|
|
32
|
+
get_channel_bot,
|
|
33
|
+
get_owner_chat_id,
|
|
34
|
+
)
|
|
35
|
+
from pascal.types import ActionStatus, ContentBlock, LLMResponse, Message, Observation, Role, ToolCall
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
_MAX_PARSE_RETRIES = 3
|
|
40
|
+
_DEFAULT_MAX_TOOL_ROUNDS = 10
|
|
41
|
+
_MESSAGE_CHAR_BUDGET = 120_000
|
|
42
|
+
_TOOL_RESULT_CHAR_BUDGET = 8_000
|
|
43
|
+
_DESKTOP_PROMPT_START = "Desktop tools (Windows):\n"
|
|
44
|
+
_DESKTOP_PROMPT_END = "External data handling:\n"
|
|
45
|
+
|
|
46
|
+
TERMINAL_ACTIONS = frozenset({"wait", "escalate", "complete_task", "fail_task"})
|
|
47
|
+
BATCH_BREAKERS = frozenset({
|
|
48
|
+
"plan", "delegate", "wait", "escalate",
|
|
49
|
+
"complete_task", "fail_task", "block_task",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _strip_prompt_section(prompt: str, start_marker: str, end_marker: str) -> str:
|
|
54
|
+
"""Remove a prompt section delimited by fixed start/end markers."""
|
|
55
|
+
before, found_start, tail = prompt.partition(start_marker)
|
|
56
|
+
if not found_start:
|
|
57
|
+
return prompt
|
|
58
|
+
_section, found_end, after = tail.partition(end_marker)
|
|
59
|
+
if not found_end:
|
|
60
|
+
return prompt
|
|
61
|
+
trimmed = f"{before}{found_end}{after}"
|
|
62
|
+
while "\n\n\n" in trimmed:
|
|
63
|
+
trimmed = trimmed.replace("\n\n\n", "\n\n")
|
|
64
|
+
return trimmed
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_SYSTEM_PROMPT_NO_DESKTOP = _strip_prompt_section(
|
|
68
|
+
SYSTEM_PROMPT,
|
|
69
|
+
_DESKTOP_PROMPT_START,
|
|
70
|
+
_DESKTOP_PROMPT_END,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Loop State ─────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class LoopState:
|
|
78
|
+
"""Mutable per-run state."""
|
|
79
|
+
actions_taken: list[dict[str, Any]] = field(default_factory=list)
|
|
80
|
+
consecutive_errors: int = 0
|
|
81
|
+
thought_buffer: list[str] = field(default_factory=list)
|
|
82
|
+
error_feedback: str = ""
|
|
83
|
+
has_unknown_step: bool = False
|
|
84
|
+
last_attachment: ContentBlock | None = None
|
|
85
|
+
skip_verification: bool = False
|
|
86
|
+
last_observation: Observation | None = None
|
|
87
|
+
action_step: int = 0
|
|
88
|
+
force_full_render: bool = True
|
|
89
|
+
cached_preset: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ToolUseResult:
|
|
94
|
+
"""Outcome of one inner tool-use loop pass."""
|
|
95
|
+
stop_reason: str = "" # text_response / terminal_action / stagnation / max_errors / round_cap
|
|
96
|
+
terminal_action: str | None = None
|
|
97
|
+
terminal_decision: dict[str, Any] = field(default_factory=dict)
|
|
98
|
+
terminal_result: dict[str, Any] = field(default_factory=dict)
|
|
99
|
+
terminal_obs: Observation | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Loop Runner ────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
class LoopRunner:
|
|
105
|
+
"""Orchestrates the Pascal tool-use loop."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
store: PascalStore,
|
|
110
|
+
llm,
|
|
111
|
+
*,
|
|
112
|
+
verifier_llm=None,
|
|
113
|
+
ledger: Ledger | None = None,
|
|
114
|
+
mcp_manager: MCPManager | None = None,
|
|
115
|
+
max_effect: str | None = None,
|
|
116
|
+
max_iterations: int = 50,
|
|
117
|
+
max_tool_rounds: int | None = None,
|
|
118
|
+
max_inner_turns: int = 1, # deprecated alias for max_tool_rounds
|
|
119
|
+
on_action: Callable[[dict[str, Any]], None] | None = None,
|
|
120
|
+
execute_command: Callable[[str], str] | None = None,
|
|
121
|
+
workspace: str | None = None,
|
|
122
|
+
wake_event: "asyncio.Event | None" = None,
|
|
123
|
+
):
|
|
124
|
+
self.store = store
|
|
125
|
+
self.llm = llm
|
|
126
|
+
self.verifier_llm = verifier_llm
|
|
127
|
+
self.ledger = ledger
|
|
128
|
+
self.mcp_manager = mcp_manager
|
|
129
|
+
self.max_effect = max_effect
|
|
130
|
+
self.on_action = on_action
|
|
131
|
+
self.execute_command = execute_command
|
|
132
|
+
self.wake_event = wake_event
|
|
133
|
+
self._iteration_limit = max_iterations if max_iterations > 0 else 999_999
|
|
134
|
+
# Priority: max_tool_rounds > max_inner_turns (deprecated alias) > default
|
|
135
|
+
if max_tool_rounds is not None:
|
|
136
|
+
self._max_tool_rounds = max(1, max_tool_rounds)
|
|
137
|
+
elif max_inner_turns > 1:
|
|
138
|
+
self._max_tool_rounds = max(1, max_inner_turns)
|
|
139
|
+
else:
|
|
140
|
+
self._max_tool_rounds = _DEFAULT_MAX_TOOL_ROUNDS
|
|
141
|
+
self.state = LoopState()
|
|
142
|
+
self.trust_map = TrustMap.load(store.connection)
|
|
143
|
+
|
|
144
|
+
import uuid as _uuid
|
|
145
|
+
self.run_id = f"run_{_uuid.uuid4().hex[:8]}"
|
|
146
|
+
|
|
147
|
+
self._locked = not store.acquire_lock(self.run_id)
|
|
148
|
+
if self._locked:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
# Identity seed
|
|
152
|
+
if not store.get_context("identity"):
|
|
153
|
+
from datetime import datetime, timezone
|
|
154
|
+
store.set_context("identity", {
|
|
155
|
+
"name": "Pascal",
|
|
156
|
+
"companion_since": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
157
|
+
"personality": "Calm, practical, opinionated when it matters. Minimal unnecessary talk.",
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
# MCP + skills + tool schemas
|
|
161
|
+
mcp_tool_specs = mcp_manager.all_tool_specs() if mcp_manager else []
|
|
162
|
+
skill_list = _load_skills()
|
|
163
|
+
self.desk = Desk(store, mcp_tools=mcp_tool_specs, skills=skill_list)
|
|
164
|
+
|
|
165
|
+
from pascal.schemas import build_tool_schemas
|
|
166
|
+
self._tool_schemas_cache = {
|
|
167
|
+
"minimal": build_tool_schemas(preset="minimal"),
|
|
168
|
+
"standard": build_tool_schemas(preset="standard"),
|
|
169
|
+
"full": build_tool_schemas(preset="full"),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
ws = workspace or os.getcwd()
|
|
173
|
+
set_workspace(ws)
|
|
174
|
+
self.sandbox = SandboxManager(workspace=ws)
|
|
175
|
+
|
|
176
|
+
# Action context — single object for all handler calls
|
|
177
|
+
self.action_ctx = ActionContext(
|
|
178
|
+
store=store, llm=verifier_llm, max_effect=max_effect,
|
|
179
|
+
sandbox=self.sandbox, mcp_manager=mcp_manager,
|
|
180
|
+
execute_command=execute_command,
|
|
181
|
+
thought_buffer=self.state.thought_buffer,
|
|
182
|
+
ledger=ledger, notify_fn=_notify_channel,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Resume context
|
|
186
|
+
self.resume_context, resumed_unknown = _compile_resume_context(store)
|
|
187
|
+
if resumed_unknown:
|
|
188
|
+
self.state.has_unknown_step = True
|
|
189
|
+
|
|
190
|
+
# Auto-pick sole actionable task
|
|
191
|
+
if not store.get_active_task():
|
|
192
|
+
actionable = _get_actionable_pending_tasks(store)
|
|
193
|
+
if len(actionable) == 1:
|
|
194
|
+
store.activate_task(actionable[0]["id"])
|
|
195
|
+
logger.info("Auto-picked sole actionable task: %s", actionable[0]["goal"][:80])
|
|
196
|
+
|
|
197
|
+
# ── Initial messages ──────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def _render_system_prompt(self, preset: str) -> str:
|
|
200
|
+
"""Render the system prompt variant for the active tool preset."""
|
|
201
|
+
base_prompt = SYSTEM_PROMPT if preset == "full" else _SYSTEM_PROMPT_NO_DESKTOP
|
|
202
|
+
static_prompt = self.desk.render_static()
|
|
203
|
+
if static_prompt:
|
|
204
|
+
return f"{base_prompt}\n\n{static_prompt}"
|
|
205
|
+
return base_prompt
|
|
206
|
+
|
|
207
|
+
def _build_initial_messages(self) -> list[Message]:
|
|
208
|
+
"""Build the initial message list with full desk render."""
|
|
209
|
+
system_prompt = self._render_system_prompt(self._refresh_tool_preset())
|
|
210
|
+
|
|
211
|
+
desk_prompt = self.desk.render(
|
|
212
|
+
has_unknown_step=self.state.has_unknown_step,
|
|
213
|
+
include_static=False,
|
|
214
|
+
)
|
|
215
|
+
user_content = f"Your desk:\n\n{desk_prompt}"
|
|
216
|
+
if self.resume_context:
|
|
217
|
+
user_content += f"\n\n## Resume Context\n{self.resume_context}"
|
|
218
|
+
if self.state.error_feedback:
|
|
219
|
+
user_content += f"\n\n## ERROR from previous\n{self.state.error_feedback}"
|
|
220
|
+
self.state.error_feedback = ""
|
|
221
|
+
user_content += "\n\nDecide your next action."
|
|
222
|
+
|
|
223
|
+
messages = [Message(role=Role.SYSTEM, content=system_prompt)]
|
|
224
|
+
messages.append(Message(role=Role.USER, content=user_content))
|
|
225
|
+
return messages
|
|
226
|
+
|
|
227
|
+
# ── Tool preset selection ────────────────────────────
|
|
228
|
+
|
|
229
|
+
def _refresh_tool_preset(self) -> str:
|
|
230
|
+
"""Recompute and cache the tool preset. Called once per outer iteration."""
|
|
231
|
+
if not self.store.get_active_task():
|
|
232
|
+
preset = "minimal"
|
|
233
|
+
elif self.mcp_manager and self.mcp_manager.connected_servers:
|
|
234
|
+
preset = "full"
|
|
235
|
+
else:
|
|
236
|
+
preset = "standard"
|
|
237
|
+
self.state.cached_preset = preset
|
|
238
|
+
return preset
|
|
239
|
+
|
|
240
|
+
def _select_tool_preset(self) -> str:
|
|
241
|
+
"""Return cached preset (avoids DB query on every inner loop round)."""
|
|
242
|
+
return self.state.cached_preset or self._refresh_tool_preset()
|
|
243
|
+
|
|
244
|
+
# ── LLM call ──────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
async def _call_llm(self, messages: list[Message]) -> LLMResponse | None:
|
|
247
|
+
"""Call LLM with tool schemas. Returns None on failure."""
|
|
248
|
+
try:
|
|
249
|
+
preset = self._select_tool_preset()
|
|
250
|
+
if messages and messages[0].role == Role.SYSTEM:
|
|
251
|
+
messages[0].content = self._render_system_prompt(preset)
|
|
252
|
+
response = await _llm_call_with_retry(
|
|
253
|
+
self.llm,
|
|
254
|
+
messages,
|
|
255
|
+
tools=self._tool_schemas_cache[preset],
|
|
256
|
+
)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
logger.error("LLM call failed after retries: %s", exc)
|
|
259
|
+
self.state.consecutive_errors += 1
|
|
260
|
+
if self.state.consecutive_errors >= _MAX_PARSE_RETRIES:
|
|
261
|
+
self.store.record("Loop stopped: repeated LLM failures")
|
|
262
|
+
return None
|
|
263
|
+
if not response.text and not response.tool_calls:
|
|
264
|
+
self.state.consecutive_errors += 1
|
|
265
|
+
if self.state.consecutive_errors >= _MAX_PARSE_RETRIES:
|
|
266
|
+
self.store.record("Loop stopped: repeated empty LLM responses")
|
|
267
|
+
return None
|
|
268
|
+
return response
|
|
269
|
+
|
|
270
|
+
# ── Safety checks (unchanged) ─────────────────────────
|
|
271
|
+
|
|
272
|
+
def _action_domain(self, action: str, decision: dict[str, Any]) -> str:
|
|
273
|
+
hint = str(
|
|
274
|
+
decision.get("tool")
|
|
275
|
+
or decision.get("command")
|
|
276
|
+
or decision.get("reply_text")
|
|
277
|
+
or decision.get("task")
|
|
278
|
+
or ""
|
|
279
|
+
).strip()
|
|
280
|
+
return infer_domain(action, hint or None)
|
|
281
|
+
|
|
282
|
+
def _plan_effect_level(self, decision: dict[str, Any]) -> str:
|
|
283
|
+
max_level = "E1"
|
|
284
|
+
for step in _iter_plan_actions(decision):
|
|
285
|
+
step_action = str(step.get("action") or "").strip()
|
|
286
|
+
step_level = self._decision_effect_level(step_action, step)
|
|
287
|
+
if step_level is not None and effect_to_int(step_level) > effect_to_int(max_level):
|
|
288
|
+
max_level = step_level
|
|
289
|
+
return max_level
|
|
290
|
+
|
|
291
|
+
def _decision_effect_level(self, action: str, decision: dict[str, Any]) -> str | None:
|
|
292
|
+
if action == "plan":
|
|
293
|
+
return self._plan_effect_level(decision)
|
|
294
|
+
if action == "delegate":
|
|
295
|
+
return "E2"
|
|
296
|
+
if action == "handle_notification":
|
|
297
|
+
reply_text = str(decision.get("reply_text") or "").strip()
|
|
298
|
+
return "E2" if reply_text and reply_text.upper() != "NO_REPLY" else "E0"
|
|
299
|
+
if action != "execute":
|
|
300
|
+
return None
|
|
301
|
+
tool_name = str(decision.get("tool") or "").strip()
|
|
302
|
+
if tool_name:
|
|
303
|
+
tool_effect = TOOL_EFFECTS.get(tool_name)
|
|
304
|
+
if tool_effect is not None:
|
|
305
|
+
return tool_effect
|
|
306
|
+
if self.mcp_manager is not None and self.mcp_manager.has_tool(tool_name):
|
|
307
|
+
specs = [spec for spec in self.mcp_manager.all_tool_specs() if spec.name == tool_name]
|
|
308
|
+
return "E3" if (specs and specs[0].side_effects) else "E0"
|
|
309
|
+
return "E1"
|
|
310
|
+
command = str(decision.get("command") or "").strip()
|
|
311
|
+
if command:
|
|
312
|
+
return classify_command(command)
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
def _record_trust_gate(self, *, action, decision, effect_level, domain, threshold, recent_errors):
|
|
316
|
+
active = self.store.get_active_task()
|
|
317
|
+
active_id = active["id"] if active else None
|
|
318
|
+
payload = {
|
|
319
|
+
"action": action, "effect_level": effect_level, "domain": domain,
|
|
320
|
+
"threshold": threshold, "recent_error_streak": recent_errors, "decision": decision,
|
|
321
|
+
}
|
|
322
|
+
self.store.record(
|
|
323
|
+
f"trust_gate: {threshold} {action} ({effect_level})",
|
|
324
|
+
task_id=active_id, details=payload,
|
|
325
|
+
action_status="error" if threshold == "block" else "unknown",
|
|
326
|
+
)
|
|
327
|
+
if self.ledger is not None:
|
|
328
|
+
self.ledger.record("governance", {"type": "trust_gate", **payload})
|
|
329
|
+
|
|
330
|
+
def _pre_execute_checks(self, action: str, decision: dict, iteration: int) -> str | None:
|
|
331
|
+
"""Returns 'break' (stagnation=stop loop), 'continue' (block=skip), or None."""
|
|
332
|
+
if action == "think":
|
|
333
|
+
recent_thinks = sum(1 for a in reversed(self.state.actions_taken[-5:]) if a["action"] == "think")
|
|
334
|
+
if recent_thinks >= 4:
|
|
335
|
+
self.state.error_feedback = "You have been thinking too long. Execute an action now."
|
|
336
|
+
|
|
337
|
+
if action in ("execute", "plan"):
|
|
338
|
+
cmd = str(decision.get("command") or decision.get("tool") or "")
|
|
339
|
+
stag_kind = _detect_stagnation(self.store, cmd) if cmd else ""
|
|
340
|
+
if stag_kind:
|
|
341
|
+
stag_label = "same tool" if stag_kind == "tool" else "same command"
|
|
342
|
+
self.state.error_feedback = (
|
|
343
|
+
f"Stagnation detected: {stag_label} repeated 3+ times ('{cmd}'). "
|
|
344
|
+
"Try a completely different approach. "
|
|
345
|
+
"Tool access priority: API > UIA > DOM(CDP) > Shell > Screenshot"
|
|
346
|
+
)
|
|
347
|
+
self.store.record(
|
|
348
|
+
f"Stagnation: {stag_label} repeated 3+ times",
|
|
349
|
+
task_id=(self.store.get_active_task() or {}).get("id"),
|
|
350
|
+
details={"action": "escalate", "command": cmd, "reason": "stagnation_detected"},
|
|
351
|
+
action_status="error",
|
|
352
|
+
)
|
|
353
|
+
active = self.store.get_active_task()
|
|
354
|
+
if active:
|
|
355
|
+
_auto_postmortem(self.store, active, f"Stagnation: repeated '{cmd}'")
|
|
356
|
+
self.state.actions_taken.append({
|
|
357
|
+
"iteration": iteration, "action": "escalate",
|
|
358
|
+
"reason": "stagnation_detected", "result": {"stagnation": True},
|
|
359
|
+
})
|
|
360
|
+
return "break"
|
|
361
|
+
|
|
362
|
+
effect_level = self._decision_effect_level(action, decision)
|
|
363
|
+
if effect_level is None or effect_to_int(effect_level) == 0:
|
|
364
|
+
return None
|
|
365
|
+
if not check_allowed(effect_level, get_max_effect(self.max_effect)):
|
|
366
|
+
self.state.error_feedback = (
|
|
367
|
+
f"Effect level {effect_level} exceeds max allowed {get_max_effect(self.max_effect)}. "
|
|
368
|
+
"Choose a lower-effect action or escalate to a human."
|
|
369
|
+
)
|
|
370
|
+
return "continue"
|
|
371
|
+
|
|
372
|
+
recent_errors = _recent_error_streak(self.state.actions_taken)
|
|
373
|
+
domain = self._action_domain(action, decision)
|
|
374
|
+
threshold = compute_threshold(
|
|
375
|
+
effect_level, trust_map=self.trust_map, domain=domain,
|
|
376
|
+
recent_error_streak=recent_errors,
|
|
377
|
+
)
|
|
378
|
+
should_gate = threshold == "block"
|
|
379
|
+
if threshold == "caution" and (effect_to_int(effect_level) >= 4 or recent_errors >= 3):
|
|
380
|
+
should_gate = True
|
|
381
|
+
if not should_gate:
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
self._record_trust_gate(
|
|
385
|
+
action=action, decision=decision, effect_level=effect_level,
|
|
386
|
+
domain=domain, threshold=threshold, recent_errors=recent_errors,
|
|
387
|
+
)
|
|
388
|
+
self.state.error_feedback = (
|
|
389
|
+
f"Trust gate {threshold}: {action} at {effect_level} in domain '{domain}' is not allowed. "
|
|
390
|
+
"Choose a lower-effect step, gather more evidence, or escalate."
|
|
391
|
+
)
|
|
392
|
+
return "continue"
|
|
393
|
+
|
|
394
|
+
# ── Observe + Record ──────────────────────────────────
|
|
395
|
+
|
|
396
|
+
def _observe_result(self, action: str, decision: dict, result: Any) -> Observation:
|
|
397
|
+
"""Create Observation from raw result. Updates trust + state."""
|
|
398
|
+
obs = Observation.from_result(action, result)
|
|
399
|
+
self.state.last_observation = obs
|
|
400
|
+
domain = self._action_domain(action, decision)
|
|
401
|
+
|
|
402
|
+
if obs.status == ActionStatus.OK:
|
|
403
|
+
self.trust_map.update_on_success(domain)
|
|
404
|
+
elif obs.status == ActionStatus.ERROR:
|
|
405
|
+
self.trust_map.update_on_failure(domain, is_judgment_error=True)
|
|
406
|
+
elif obs.status == ActionStatus.UNKNOWN:
|
|
407
|
+
self.trust_map.update_on_failure(domain, is_judgment_error=False)
|
|
408
|
+
from pascal.capability import _clamp_score
|
|
409
|
+
if domain in self.trust_map.domains:
|
|
410
|
+
self.trust_map.domains[domain] = _clamp_score(self.trust_map.domains[domain] - 0.03)
|
|
411
|
+
|
|
412
|
+
if obs.status == ActionStatus.UNKNOWN:
|
|
413
|
+
self.state.has_unknown_step = True
|
|
414
|
+
elif obs.status == ActionStatus.OK and action == "execute":
|
|
415
|
+
self.state.has_unknown_step = False
|
|
416
|
+
|
|
417
|
+
# Vision: save attachment for next USER message
|
|
418
|
+
if obs.attachment:
|
|
419
|
+
self.state.last_attachment = obs.attachment
|
|
420
|
+
|
|
421
|
+
# Clear thought buffer after non-think
|
|
422
|
+
if action != "think":
|
|
423
|
+
self.state.thought_buffer.clear()
|
|
424
|
+
|
|
425
|
+
return obs
|
|
426
|
+
|
|
427
|
+
def _record_and_update(
|
|
428
|
+
self, obs: Observation, decision: dict,
|
|
429
|
+
reason: str, expected_evidence: str, stop_condition: str, iteration: int,
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Record action to history + ledger. No control flow."""
|
|
432
|
+
action = obs.action_type
|
|
433
|
+
result = obs.raw_result
|
|
434
|
+
|
|
435
|
+
active = self.store.get_active_task()
|
|
436
|
+
active_id = active["id"] if active else None
|
|
437
|
+
# idem_key includes action_step to prevent collisions in multi-tool iterations
|
|
438
|
+
idem_key = f"{active_id or 'notask'}:{iteration}:{self.state.action_step}:{action}"
|
|
439
|
+
|
|
440
|
+
action_record = {
|
|
441
|
+
"iteration": iteration,
|
|
442
|
+
"action": action,
|
|
443
|
+
"reason": reason,
|
|
444
|
+
"result": result,
|
|
445
|
+
}
|
|
446
|
+
self.state.actions_taken.append(action_record)
|
|
447
|
+
if self.on_action:
|
|
448
|
+
self.on_action(action_record)
|
|
449
|
+
_broadcast_action(action_record)
|
|
450
|
+
|
|
451
|
+
self.store.record(
|
|
452
|
+
f"{action}: {reason}" if reason else action,
|
|
453
|
+
task_id=active_id,
|
|
454
|
+
details={"decision": decision, "result": result},
|
|
455
|
+
idem_key=idem_key,
|
|
456
|
+
action_status=obs.status,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
if self.ledger is not None:
|
|
460
|
+
self.ledger.record_action(action, decision, result)
|
|
461
|
+
|
|
462
|
+
self.trust_map.save(self.store.connection)
|
|
463
|
+
|
|
464
|
+
# ── Stop conditions (unchanged) ───────────────────────
|
|
465
|
+
|
|
466
|
+
async def _check_stop(self, action: str, decision: dict, result: Any,
|
|
467
|
+
obs: Observation, iteration: int) -> str | None:
|
|
468
|
+
"""Returns 'break', 'continue', or None."""
|
|
469
|
+
if (
|
|
470
|
+
self.wake_event is None
|
|
471
|
+
and action == "complete_task"
|
|
472
|
+
and obs.status == ActionStatus.OK
|
|
473
|
+
and not _has_actionable_work(self.store)
|
|
474
|
+
):
|
|
475
|
+
auto_wait = {
|
|
476
|
+
"iteration": iteration, "action": "wait",
|
|
477
|
+
"reason": "auto-idle after completion",
|
|
478
|
+
"result": {"waiting": True, "auto": True},
|
|
479
|
+
}
|
|
480
|
+
self.state.actions_taken.append(auto_wait)
|
|
481
|
+
if self.on_action:
|
|
482
|
+
self.on_action(auto_wait)
|
|
483
|
+
_broadcast_action(auto_wait)
|
|
484
|
+
self.store.record(
|
|
485
|
+
"wait: auto-idle after completion",
|
|
486
|
+
details={"decision": {"action": "wait", "auto": True}, "result": auto_wait["result"]},
|
|
487
|
+
action_status="ok",
|
|
488
|
+
)
|
|
489
|
+
return "break"
|
|
490
|
+
|
|
491
|
+
if action == "wait":
|
|
492
|
+
if self.wake_event is not None:
|
|
493
|
+
self.wake_event.clear()
|
|
494
|
+
while not self.wake_event.is_set():
|
|
495
|
+
self.store.refresh_lock()
|
|
496
|
+
try:
|
|
497
|
+
await asyncio.wait_for(self.wake_event.wait(), timeout=60)
|
|
498
|
+
except asyncio.TimeoutError:
|
|
499
|
+
pass
|
|
500
|
+
return "continue"
|
|
501
|
+
else:
|
|
502
|
+
return "break"
|
|
503
|
+
|
|
504
|
+
if action == "escalate":
|
|
505
|
+
if self.wake_event is not None and get_approval_callback() is not None:
|
|
506
|
+
active = self.store.get_active_task()
|
|
507
|
+
if active:
|
|
508
|
+
question = str(decision.get("question") or "Need human input")
|
|
509
|
+
await get_approval_callback()(get_owner_chat_id(), active["id"], question)
|
|
510
|
+
self.store.update_task(active["id"], status="blocked",
|
|
511
|
+
progress=f"Awaiting approval: {question}")
|
|
512
|
+
return "continue"
|
|
513
|
+
return "break"
|
|
514
|
+
|
|
515
|
+
if isinstance(result, dict) and result.get("escalate"):
|
|
516
|
+
return "break"
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
async def _cleanup(self) -> None:
|
|
520
|
+
self.trust_map.save(self.store.connection)
|
|
521
|
+
await cleanup_tools()
|
|
522
|
+
self.store.release_lock()
|
|
523
|
+
|
|
524
|
+
# ── Tool use loop (NEW — Claude Code / Codex pattern) ─
|
|
525
|
+
|
|
526
|
+
async def _tool_use_loop(self, messages: list[Message], iteration: int) -> ToolUseResult:
|
|
527
|
+
"""Inner tool-use loop. No JSON serialization — Message.tool_calls directly."""
|
|
528
|
+
|
|
529
|
+
def _append_tool_error(tc_id: str, error: str) -> None:
|
|
530
|
+
messages.append(Message(
|
|
531
|
+
role=Role.TOOL, tool_call_id=tc_id,
|
|
532
|
+
content=json.dumps({"error": error}, ensure_ascii=False),
|
|
533
|
+
))
|
|
534
|
+
|
|
535
|
+
def _close_remaining(tool_calls: list[ToolCall], start: int, error: str) -> None:
|
|
536
|
+
for skipped in tool_calls[start:]:
|
|
537
|
+
_append_tool_error(skipped.id, error)
|
|
538
|
+
|
|
539
|
+
for _round in range(self._max_tool_rounds):
|
|
540
|
+
# Vision: pending attachment → USER message
|
|
541
|
+
if self.state.last_attachment is not None:
|
|
542
|
+
messages.append(Message(
|
|
543
|
+
role=Role.USER, content="[screenshot attached]",
|
|
544
|
+
attachments=[self.state.last_attachment],
|
|
545
|
+
))
|
|
546
|
+
self.state.last_attachment = None
|
|
547
|
+
|
|
548
|
+
# LLM call
|
|
549
|
+
response = await self._call_llm(messages)
|
|
550
|
+
if response is None:
|
|
551
|
+
if self.state.consecutive_errors >= _MAX_PARSE_RETRIES:
|
|
552
|
+
return ToolUseResult(stop_reason="max_errors")
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
# Extract tool_calls from function calling response
|
|
556
|
+
tool_calls = list(response.tool_calls or [])
|
|
557
|
+
self.state.consecutive_errors = 0
|
|
558
|
+
|
|
559
|
+
# No tool_calls = text response → done
|
|
560
|
+
if not tool_calls:
|
|
561
|
+
if response.text:
|
|
562
|
+
messages.append(Message(role=Role.ASSISTANT, content=response.text))
|
|
563
|
+
return ToolUseResult(stop_reason="text_response")
|
|
564
|
+
|
|
565
|
+
# Assistant message — tool_calls directly, no JSON serialization
|
|
566
|
+
messages.append(Message(
|
|
567
|
+
role=Role.ASSISTANT,
|
|
568
|
+
content=response.text or "",
|
|
569
|
+
tool_calls=tool_calls,
|
|
570
|
+
))
|
|
571
|
+
|
|
572
|
+
# Execute each tool_call sequentially
|
|
573
|
+
for idx, tc in enumerate(tool_calls):
|
|
574
|
+
action = tc.name.strip().lower()
|
|
575
|
+
decision = dict(tc.params) if tc.params else {}
|
|
576
|
+
decision["action"] = action
|
|
577
|
+
self.state.action_step += 1
|
|
578
|
+
|
|
579
|
+
# Safety check
|
|
580
|
+
signal = self._pre_execute_checks(action, decision, iteration)
|
|
581
|
+
if signal == "break":
|
|
582
|
+
_append_tool_error(tc.id, "stagnation detected, loop terminated")
|
|
583
|
+
_close_remaining(tool_calls, idx + 1,
|
|
584
|
+
"skipped: loop terminated after earlier stagnation")
|
|
585
|
+
return ToolUseResult(stop_reason="stagnation")
|
|
586
|
+
if signal == "continue":
|
|
587
|
+
_append_tool_error(tc.id, self.state.error_feedback or "blocked")
|
|
588
|
+
_close_remaining(tool_calls, idx + 1,
|
|
589
|
+
"skipped: earlier tool call was blocked; replan required")
|
|
590
|
+
break
|
|
591
|
+
|
|
592
|
+
# Execute — use ActionContext for uniform handler dispatch
|
|
593
|
+
self.action_ctx.skip_verification = self.state.skip_verification
|
|
594
|
+
result = await execute_action(
|
|
595
|
+
self.store, decision, action, ctx=self.action_ctx,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# Observe + record immediately
|
|
599
|
+
obs = self._observe_result(action, decision, result)
|
|
600
|
+
reason = str(decision.get("reason") or decision.get("thought") or "")
|
|
601
|
+
self._record_and_update(obs, decision, reason, "", "", iteration)
|
|
602
|
+
|
|
603
|
+
# Tool result — raw_result, always valid JSON
|
|
604
|
+
result_content = json.dumps(result, ensure_ascii=False, default=_json_safe)
|
|
605
|
+
if len(result_content) > _TOOL_RESULT_CHAR_BUDGET:
|
|
606
|
+
result_content = json.dumps(
|
|
607
|
+
{"truncated": True, "preview": result_content[:_TOOL_RESULT_CHAR_BUDGET]},
|
|
608
|
+
ensure_ascii=False,
|
|
609
|
+
)
|
|
610
|
+
messages.append(Message(
|
|
611
|
+
role=Role.TOOL, tool_call_id=tc.id, content=result_content,
|
|
612
|
+
))
|
|
613
|
+
|
|
614
|
+
# Batch-breaker → stop batch, let LLM replan
|
|
615
|
+
if action in BATCH_BREAKERS:
|
|
616
|
+
_close_remaining(tool_calls, idx + 1,
|
|
617
|
+
f"skipped: '{action}' ended the tool batch; replan required")
|
|
618
|
+
if action in TERMINAL_ACTIONS:
|
|
619
|
+
return ToolUseResult(
|
|
620
|
+
stop_reason="terminal_action",
|
|
621
|
+
terminal_action=action,
|
|
622
|
+
terminal_decision=decision,
|
|
623
|
+
terminal_result=result,
|
|
624
|
+
terminal_obs=obs,
|
|
625
|
+
)
|
|
626
|
+
break
|
|
627
|
+
|
|
628
|
+
# Failure → stop batch
|
|
629
|
+
if obs.status != ActionStatus.OK:
|
|
630
|
+
_close_remaining(tool_calls, idx + 1,
|
|
631
|
+
f"skipped: '{action}' failed; replan required")
|
|
632
|
+
break
|
|
633
|
+
|
|
634
|
+
return ToolUseResult(stop_reason="round_cap")
|
|
635
|
+
|
|
636
|
+
# Text JSON fallback removed — all providers now support function calling.
|
|
637
|
+
|
|
638
|
+
# ── Context management ────────────────────────────────
|
|
639
|
+
|
|
640
|
+
def _messages_too_long(self, messages: list[Message]) -> bool:
|
|
641
|
+
return sum(len(m.content or "") for m in messages) > _MESSAGE_CHAR_BUDGET
|
|
642
|
+
|
|
643
|
+
def _compact_messages(self, messages: list[Message]) -> list[Message]:
|
|
644
|
+
"""Trim messages while preserving assistant-tool batch integrity.
|
|
645
|
+
|
|
646
|
+
Dropped messages are saved to ~/.pascal/scratch/ so the agent can
|
|
647
|
+
recover context by reading the file if needed (filesystem-as-memory pattern).
|
|
648
|
+
"""
|
|
649
|
+
if not messages:
|
|
650
|
+
return messages
|
|
651
|
+
system = messages[0]
|
|
652
|
+
tail = messages[1:]
|
|
653
|
+
recent = tail[-8:]
|
|
654
|
+
|
|
655
|
+
# Ensure no orphaned TOOL or partial batches
|
|
656
|
+
while recent:
|
|
657
|
+
first = recent[0]
|
|
658
|
+
if first.role == Role.TOOL:
|
|
659
|
+
idx = len(messages) - len(recent) - 1
|
|
660
|
+
while idx >= 1 and messages[idx].role == Role.TOOL:
|
|
661
|
+
idx -= 1
|
|
662
|
+
if idx >= 1 and messages[idx].role == Role.ASSISTANT:
|
|
663
|
+
recent = [messages[idx]] + recent
|
|
664
|
+
continue
|
|
665
|
+
recent = recent[1:]
|
|
666
|
+
continue
|
|
667
|
+
if first.role == Role.ASSISTANT and first.tool_calls:
|
|
668
|
+
needed = len(first.tool_calls)
|
|
669
|
+
found = sum(1 for m in recent[1:1 + needed] if m.role == Role.TOOL)
|
|
670
|
+
if found < needed:
|
|
671
|
+
recent = recent[1 + found:]
|
|
672
|
+
continue
|
|
673
|
+
break
|
|
674
|
+
|
|
675
|
+
# Save dropped messages to scratch file (filesystem-as-memory)
|
|
676
|
+
dropped = tail[:-8] if len(tail) > 8 else []
|
|
677
|
+
scratch_ref = ""
|
|
678
|
+
if dropped:
|
|
679
|
+
scratch_ref = self._save_scratch(dropped)
|
|
680
|
+
|
|
681
|
+
delta_content = f"[context_refresh]\n{self.desk.render_delta(observation=self.state.last_observation)}"
|
|
682
|
+
if scratch_ref:
|
|
683
|
+
delta_content += f"\n\nPrevious context saved to: {scratch_ref} (use read_file if needed)"
|
|
684
|
+
delta = Message(role=Role.USER, content=delta_content)
|
|
685
|
+
return [system, delta, *recent]
|
|
686
|
+
|
|
687
|
+
def _save_scratch(self, messages: list[Message]) -> str:
|
|
688
|
+
"""Save dropped messages to ~/.pascal/scratch/{run_id}.md. Returns path."""
|
|
689
|
+
from pathlib import Path as _Path
|
|
690
|
+
scratch_dir = _Path.home() / ".pascal" / "scratch"
|
|
691
|
+
scratch_dir.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
path = scratch_dir / f"{self.run_id}_context.md"
|
|
693
|
+
lines = []
|
|
694
|
+
for msg in messages:
|
|
695
|
+
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
|
696
|
+
content = (msg.content or "")[:500]
|
|
697
|
+
lines.append(f"## [{role}]\n{content}\n")
|
|
698
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
699
|
+
return str(path)
|
|
700
|
+
|
|
701
|
+
# ── Main loop ─────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
async def run(self) -> list[dict[str, Any]]:
|
|
704
|
+
"""Run the tool-use loop. Returns action log."""
|
|
705
|
+
if self._locked:
|
|
706
|
+
return [{"action": "wait", "reason": "another run is active", "result": {"locked": True}}]
|
|
707
|
+
|
|
708
|
+
messages: list[Message] = self._build_initial_messages()
|
|
709
|
+
self.state.force_full_render = False # consumed
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
for iteration in range(self._iteration_limit):
|
|
713
|
+
self.store.refresh_lock()
|
|
714
|
+
|
|
715
|
+
# Desk sync for subsequent iterations
|
|
716
|
+
if iteration > 0:
|
|
717
|
+
self._refresh_tool_preset()
|
|
718
|
+
if self.state.force_full_render:
|
|
719
|
+
messages = self._build_initial_messages()
|
|
720
|
+
self.state.force_full_render = False
|
|
721
|
+
else:
|
|
722
|
+
delta = self.desk.render_delta(observation=self.state.last_observation)
|
|
723
|
+
messages.append(Message(
|
|
724
|
+
role=Role.USER,
|
|
725
|
+
content=f"[desk update]\n{delta}\n\nDecide your next action.",
|
|
726
|
+
))
|
|
727
|
+
|
|
728
|
+
# Tool use loop
|
|
729
|
+
result = await self._tool_use_loop(messages, iteration)
|
|
730
|
+
|
|
731
|
+
# Checkpoint
|
|
732
|
+
active = self.store.get_active_task()
|
|
733
|
+
if active:
|
|
734
|
+
self.store.save_checkpoint(active["id"], iteration, {
|
|
735
|
+
"has_unknown_step": self.state.has_unknown_step,
|
|
736
|
+
})
|
|
737
|
+
elif result.terminal_action in ("complete_task", "fail_task"):
|
|
738
|
+
# Task is no longer active — find from recent history
|
|
739
|
+
recent = self.store.get_recent_history(limit=3)
|
|
740
|
+
for h in recent:
|
|
741
|
+
if h.get("task_id"):
|
|
742
|
+
self.store.save_checkpoint(h["task_id"], iteration, {
|
|
743
|
+
"has_unknown_step": self.state.has_unknown_step,
|
|
744
|
+
})
|
|
745
|
+
break
|
|
746
|
+
|
|
747
|
+
# Terminal action → check stop
|
|
748
|
+
if result.terminal_action and result.terminal_obs is not None:
|
|
749
|
+
stop = await self._check_stop(
|
|
750
|
+
result.terminal_action, result.terminal_decision,
|
|
751
|
+
result.terminal_result, result.terminal_obs, iteration,
|
|
752
|
+
)
|
|
753
|
+
if stop == "break":
|
|
754
|
+
break
|
|
755
|
+
if stop == "continue":
|
|
756
|
+
self.state.force_full_render = True
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
# Non-terminal stops
|
|
760
|
+
if result.stop_reason in ("stagnation", "max_errors"):
|
|
761
|
+
break
|
|
762
|
+
|
|
763
|
+
# Context management
|
|
764
|
+
if self._messages_too_long(messages):
|
|
765
|
+
messages = self._compact_messages(messages)
|
|
766
|
+
|
|
767
|
+
finally:
|
|
768
|
+
await self._cleanup()
|
|
769
|
+
|
|
770
|
+
return self.state.actions_taken
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
async def run_loop(
|
|
774
|
+
store: PascalStore,
|
|
775
|
+
llm,
|
|
776
|
+
*,
|
|
777
|
+
verifier_llm=None,
|
|
778
|
+
ledger: Ledger | None = None,
|
|
779
|
+
mcp_manager: MCPManager | None = None,
|
|
780
|
+
max_effect: str | None = None,
|
|
781
|
+
max_iterations: int = 50,
|
|
782
|
+
max_tool_rounds: int | None = None,
|
|
783
|
+
max_inner_turns: int = 1, # deprecated alias for max_tool_rounds
|
|
784
|
+
on_action: Callable[[dict[str, Any]], None] | None = None,
|
|
785
|
+
execute_command: Callable[[str], str] | None = None,
|
|
786
|
+
workspace: str | None = None,
|
|
787
|
+
wake_event: "asyncio.Event | None" = None,
|
|
788
|
+
) -> list[dict[str, Any]]:
|
|
789
|
+
"""Run the Pascal loop. Returns the action log."""
|
|
790
|
+
runner = LoopRunner(
|
|
791
|
+
store, llm,
|
|
792
|
+
verifier_llm=verifier_llm, ledger=ledger, mcp_manager=mcp_manager,
|
|
793
|
+
max_effect=max_effect, max_iterations=max_iterations,
|
|
794
|
+
max_tool_rounds=max_tool_rounds, max_inner_turns=max_inner_turns,
|
|
795
|
+
on_action=on_action, execute_command=execute_command,
|
|
796
|
+
workspace=workspace, wake_event=wake_event,
|
|
797
|
+
)
|
|
798
|
+
return await runner.run()
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# ── Helpers ───────────────────────────────────────────────
|
|
802
|
+
|
|
803
|
+
def _get_actionable_pending_tasks(store: PascalStore) -> list[dict[str, Any]]:
|
|
804
|
+
pending = store.get_pending_tasks()
|
|
805
|
+
if not pending:
|
|
806
|
+
return []
|
|
807
|
+
done_ids = {
|
|
808
|
+
r["id"] for r in store.connection.execute("SELECT id FROM tasks WHERE status = 'done'").fetchall()
|
|
809
|
+
}
|
|
810
|
+
actionable = []
|
|
811
|
+
for task in pending:
|
|
812
|
+
deps = []
|
|
813
|
+
if task.get("depends_on"):
|
|
814
|
+
try:
|
|
815
|
+
deps = json.loads(task["depends_on"])
|
|
816
|
+
except (json.JSONDecodeError, TypeError):
|
|
817
|
+
deps = []
|
|
818
|
+
if deps and not all(dep in done_ids for dep in deps):
|
|
819
|
+
continue
|
|
820
|
+
actionable.append(task)
|
|
821
|
+
actionable.sort(key=lambda t: (
|
|
822
|
+
{"urgent": 0, "normal": 1, "low": 2}.get(t.get("priority", "normal"), 1),
|
|
823
|
+
t.get("due_at") or "9999", t.get("created_at") or "",
|
|
824
|
+
))
|
|
825
|
+
return actionable
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _has_actionable_work(store: PascalStore) -> bool:
|
|
829
|
+
if store.get_active_task():
|
|
830
|
+
return True
|
|
831
|
+
if store.get_pending_notifications():
|
|
832
|
+
return True
|
|
833
|
+
return bool(_get_actionable_pending_tasks(store))
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _recent_error_streak(actions_taken: list[dict[str, Any]]) -> int:
|
|
837
|
+
streak = 0
|
|
838
|
+
for rec in reversed(actions_taken):
|
|
839
|
+
result = rec.get("result")
|
|
840
|
+
if not isinstance(result, dict):
|
|
841
|
+
break
|
|
842
|
+
status = str(result.get("status") or "").strip().lower()
|
|
843
|
+
if status not in {"error", "unknown"}:
|
|
844
|
+
break
|
|
845
|
+
if rec.get("action") not in {"execute", "plan"}:
|
|
846
|
+
break
|
|
847
|
+
streak += 1
|
|
848
|
+
return streak
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _iter_plan_actions(decision: dict[str, Any]):
|
|
852
|
+
steps = decision.get("steps")
|
|
853
|
+
if isinstance(steps, list):
|
|
854
|
+
for step in steps:
|
|
855
|
+
if isinstance(step, dict):
|
|
856
|
+
yield step
|
|
857
|
+
plan_tree = decision.get("plan_tree")
|
|
858
|
+
if not isinstance(plan_tree, dict):
|
|
859
|
+
return
|
|
860
|
+
stack = [plan_tree]
|
|
861
|
+
while stack:
|
|
862
|
+
node = stack.pop()
|
|
863
|
+
action = node.get("action")
|
|
864
|
+
if isinstance(action, dict):
|
|
865
|
+
yield action
|
|
866
|
+
children = node.get("children")
|
|
867
|
+
if isinstance(children, list):
|
|
868
|
+
for child in reversed(children):
|
|
869
|
+
if isinstance(child, dict):
|
|
870
|
+
stack.append(child)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _load_skills() -> list[dict[str, str]]:
|
|
874
|
+
from pathlib import Path as _Path
|
|
875
|
+
skills_dir = _Path.home() / ".pascal" / "skills"
|
|
876
|
+
if not skills_dir.is_dir():
|
|
877
|
+
return []
|
|
878
|
+
skills = []
|
|
879
|
+
for path in sorted(skills_dir.glob("*.md")):
|
|
880
|
+
try:
|
|
881
|
+
text = path.read_text(encoding="utf-8")
|
|
882
|
+
if text.startswith("---"):
|
|
883
|
+
end = text.index("---", 3)
|
|
884
|
+
frontmatter = text[3:end].strip()
|
|
885
|
+
name = ""
|
|
886
|
+
description = ""
|
|
887
|
+
for line in frontmatter.split("\n"):
|
|
888
|
+
if line.startswith("name:"):
|
|
889
|
+
name = line.split(":", 1)[1].strip().strip('"\'')
|
|
890
|
+
elif line.startswith("description:"):
|
|
891
|
+
description = line.split(":", 1)[1].strip().strip('"\'')
|
|
892
|
+
if name:
|
|
893
|
+
skills.append({"name": name, "description": description, "file": str(path)})
|
|
894
|
+
except Exception:
|
|
895
|
+
continue
|
|
896
|
+
return skills
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
async def _llm_call_with_retry(llm, messages, max_retries: int = 3, tools: list | None = None) -> LLMResponse:
|
|
900
|
+
last_exc = None
|
|
901
|
+
for attempt in range(max_retries):
|
|
902
|
+
try:
|
|
903
|
+
return await llm.chat(messages, tools=tools or [])
|
|
904
|
+
except Exception as exc:
|
|
905
|
+
last_exc = exc
|
|
906
|
+
exc_str = str(exc).lower()
|
|
907
|
+
if any(kw in exc_str for kw in ("429", "rate", "timeout", "502", "503", "504", "connection")):
|
|
908
|
+
wait = min(2 ** attempt, 30)
|
|
909
|
+
logger.warning("LLM transient error (attempt %d/%d), retrying in %ds: %s",
|
|
910
|
+
attempt + 1, max_retries, wait, exc)
|
|
911
|
+
await asyncio.sleep(wait)
|
|
912
|
+
continue
|
|
913
|
+
raise
|
|
914
|
+
assert last_exc is not None, "LLM retry loop exhausted with no exception"
|
|
915
|
+
raise last_exc
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
async def _notify_channel(message: str) -> None:
|
|
919
|
+
from pascal.trust import scan as _trust_scan
|
|
920
|
+
if _trust_scan(message).verdict == "block":
|
|
921
|
+
logger.warning("Status message blocked by trust scanner")
|
|
922
|
+
return
|
|
923
|
+
try:
|
|
924
|
+
bot = get_channel_bot()
|
|
925
|
+
owner = get_owner_chat_id()
|
|
926
|
+
if bot is not None and owner:
|
|
927
|
+
await bot.send_message(chat_id=owner, text=message)
|
|
928
|
+
return
|
|
929
|
+
except Exception:
|
|
930
|
+
pass
|
|
931
|
+
try:
|
|
932
|
+
from pascal.scheduler import _send_webhook
|
|
933
|
+
import os as _os
|
|
934
|
+
for url_key in ("PASCAL_SLACK_WEBHOOK", "PASCAL_DISCORD_WEBHOOK"):
|
|
935
|
+
url = _os.environ.get(url_key, "")
|
|
936
|
+
if url:
|
|
937
|
+
_send_webhook(url, message)
|
|
938
|
+
return
|
|
939
|
+
except Exception:
|
|
940
|
+
pass
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
_status_callbacks: list = []
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def add_status_callback(fn) -> None:
|
|
947
|
+
_status_callbacks.append(fn)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _broadcast_action(action_record: dict[str, Any]) -> None:
|
|
951
|
+
action = action_record.get("action", "?")
|
|
952
|
+
reason = action_record.get("reason", "")
|
|
953
|
+
result = action_record.get("result", {})
|
|
954
|
+
status = result.get("status", "") if isinstance(result, dict) else ""
|
|
955
|
+
icons = {"execute": ">", "think": "\U0001f4ad", "pick_task": "+", "complete_task": "\u2713",
|
|
956
|
+
"fail_task": "\u2717", "delegate": "\u2192", "plan": "\U0001f4cb", "wait": ".", "escalate": "?",
|
|
957
|
+
"handle_notification": "!", "memorize": "\U0001f4dd"}
|
|
958
|
+
icon = icons.get(action, " ")
|
|
959
|
+
summary = f"[{icon}] {action}: {reason}"
|
|
960
|
+
if status and status != "ok":
|
|
961
|
+
summary += f" ({status})"
|
|
962
|
+
for fn in _status_callbacks:
|
|
963
|
+
try:
|
|
964
|
+
fn(summary)
|
|
965
|
+
except Exception:
|
|
966
|
+
pass
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _detect_stagnation(store: PascalStore, current_command: str, window: int = 3) -> str:
|
|
970
|
+
"""Detect command/tool repetition stagnation.
|
|
971
|
+
|
|
972
|
+
Returns "" (no stagnation), "command" (same command repeated), or "tool" (same tool repeated).
|
|
973
|
+
"""
|
|
974
|
+
recent = store.get_recent_history(window * 3)
|
|
975
|
+
execute_records: list[str] = []
|
|
976
|
+
tool_names: list[str] = []
|
|
977
|
+
for record in reversed(recent):
|
|
978
|
+
details = record.get("details") or {}
|
|
979
|
+
if isinstance(details, str):
|
|
980
|
+
try:
|
|
981
|
+
details = json.loads(details)
|
|
982
|
+
except (json.JSONDecodeError, TypeError):
|
|
983
|
+
details = {}
|
|
984
|
+
rec_decision = details.get("decision") or details
|
|
985
|
+
if rec_decision.get("action") in ("execute", "plan"):
|
|
986
|
+
command = str(rec_decision.get("command") or "").strip()
|
|
987
|
+
tool = str(rec_decision.get("tool") or "").strip()
|
|
988
|
+
if not command and not tool:
|
|
989
|
+
continue
|
|
990
|
+
execute_records.append(command or tool)
|
|
991
|
+
tool_names.append(tool)
|
|
992
|
+
if len(execute_records) >= window:
|
|
993
|
+
break
|
|
994
|
+
if len(execute_records) < window:
|
|
995
|
+
return ""
|
|
996
|
+
if all(cmd == current_command for cmd in execute_records):
|
|
997
|
+
return "command"
|
|
998
|
+
first_tool = tool_names[0]
|
|
999
|
+
if first_tool and all(t == first_tool for t in tool_names):
|
|
1000
|
+
return "tool"
|
|
1001
|
+
return ""
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _compile_resume_context(store: PascalStore) -> tuple[str, bool]:
|
|
1005
|
+
parts: list[str] = []
|
|
1006
|
+
has_unknown = False
|
|
1007
|
+
active = store.get_active_task()
|
|
1008
|
+
if active:
|
|
1009
|
+
cp = store.get_latest_checkpoint(active["id"])
|
|
1010
|
+
if cp:
|
|
1011
|
+
snap = cp.get("snapshot") or {}
|
|
1012
|
+
parts.append(f"Resuming task: {active['goal']} (from checkpoint)")
|
|
1013
|
+
has_unknown = bool(snap.get("has_unknown_step"))
|
|
1014
|
+
if active.get("progress"):
|
|
1015
|
+
parts.append(f"Last progress: {active['progress']}")
|
|
1016
|
+
todos = store.get_todos(active["id"])
|
|
1017
|
+
if todos:
|
|
1018
|
+
done = sum(1 for t in todos if t["status"] == "done")
|
|
1019
|
+
parts.append(f"TODOs: {done}/{len(todos)} completed")
|
|
1020
|
+
next_todo = next((t for t in todos if t["status"] == "pending"), None)
|
|
1021
|
+
if next_todo:
|
|
1022
|
+
parts.append(f"Next step: {next_todo['title']}")
|
|
1023
|
+
|
|
1024
|
+
history = store.get_recent_history(limit=5)
|
|
1025
|
+
if history:
|
|
1026
|
+
parts.append("Recent actions:")
|
|
1027
|
+
for h in history:
|
|
1028
|
+
parts.append(f" - {h['summary']}")
|
|
1029
|
+
|
|
1030
|
+
for channel in ("telegram", "discord"):
|
|
1031
|
+
convos = store.get_recent_conversation(channel, limit=5)
|
|
1032
|
+
if convos:
|
|
1033
|
+
parts.append(f"Recent conversation ({channel}):")
|
|
1034
|
+
for c in convos:
|
|
1035
|
+
parts.append(f" [{c['role']}] {c['content'][:80]}")
|
|
1036
|
+
|
|
1037
|
+
return "\n".join(parts), has_unknown
|