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