Files
passepartout/org/core-loop.org
Amr Gharbeia e0a47575e9
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 3s
feat: REPL development tool + naming drift fixes + HITL gateways
REPL tool:
- ~/.opencode/bin/repl — connects to running daemon, evaluates Lisp forms,
  returns results. Usage: repl '(+ 1 2)' or via stdin.
- Server-side handler in programming-repl skill registers for :repl-eval
  sensor, bypasses LLM pipeline, writes result back through reply-stream.
- Core provides pre-reason-handler registry (register-pre-reason-handler)
  for skills to register custom sensors without modifying core code.

HITL gateway integration:
- hitl-handle-message: TUI, Telegram, and Signal gateways intercept
  approval/deny commands before they reach the LLM.
- hitl-create/hitl-approve/hitl-deny: in-memory HITL store with correlation
  tokens for gateway-agnostic approval.
- loop-gate-perceive detects HITL commands and blocks LLM processing.

Naming drift fixes (the complete batch):
- register-actuator vs actuator-register — fixed to register-actuator
- process-signal vs loop-process — alias added
- perceive-gate/reason-gate/act-gate vs loop-gate-* — aliases added
- initialize-actuators vs actuator-initialize — fixed to actuator-initialize
- initialize-all-skills vs skill-initialize-all — fixed to skill-initialize-all
- inject-stimulus alias added for backward compatibility
- All original gateway-manager inject-stimulus → stimulus-inject + HITL check
2026-05-03 13:46:32 -04:00

312 lines
13 KiB
Org Mode

#+TITLE: The Metabolic Loop (loop.lisp)
#+AUTHOR: Agent
#+FILETAGS: :harness:loop:
#+STARTUP: content
#+PROPERTY: header-args:lisp :tangle ../lisp/core-loop.lisp
* Overview: Architectural Intent
The Metabolic Loop is the cranial nerve reflex of Passepartout. While skills provide specialized intelligence, the loop provides the fundamental rhythm of existence: the continuous processing of signals from perception through cognition to action.
Every signal flows through three stages:
1. **Perceive** — normalize raw input into a standard Signal format
2. **Reason** — think (LLM) then verify (deterministic gates)
3. **Act** — dispatch the approved action to the appropriate actuator
If a stage produces a new signal (e.g., the Act stage produces a tool-output event), that signal feeds back into Perceive and the loop continues. This is how the agent has multi-step conversations: each LLM response produces an action, which produces a tool output, which feeds back as a new perception, which triggers the next reasoning cycle.
** Why Separate Stages?
A single function that called the LLM, checked safety, and executed the result would be simpler to write. But it would be impossible to:
- Test each stage independently (a bug in the LLM call would block safety testing)
- Insert new stages between P and R or R and A (adding consensus means adding a gate in the middle)
- Recover from failures mid-pipeline (an LLM timeout shouldn't prevent safety checks on the next cycle)
The stage separation is the functional equivalent of the "thin harness" principle: each stage is a pure function that transforms a signal. The loop is the composition of these functions.
** Why the Depth Limit?
A signal that generates another signal that generates another signal can infinite-loop. The depth limit (max 10) prevents this. If depth exceeds 10, the signal is silently dropped. This is the metabolic loop's circuit breaker.
The three-tier error recovery model:
1. **Transient errors** (tool failures, network timeouts) — recoverable, generate a :loop-error signal at higher depth for retry
2. **Critical errors** (undefined functions, malformed data) — require memory rollback to the last snapshot
3. **Recursive loops** (signals generating more signals indefinitely) — depth limit enforcement
* Implementation
** Package Context
#+begin_src lisp
(in-package :passepartout)
#+end_src
** Global Interrupt State
Thread-safe interrupt flag. The ~*loop-interrupt-lock*~ mutex protects access so that the signal handler and the main loop don't race on shutdown.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *interrupt-flag* nil
"Atomic flag set by signal handlers to trigger graceful shutdown.")
#+end_src
** *loop-interrupt-lock*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *loop-interrupt-lock* (bt:make-lock "harness-interrupt-lock")
"Mutex protecting *interrupt-flag* access.")
#+end_src
** *heartbeat-thread*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *heartbeat-thread* nil
"Handle to the heartbeat thread.")
#+end_src
#+end_src
** Core Engine (loop-process)
The entry point to the metabolic pipeline. Each cycle runs Perceive → Reason → Act. If Act produces feedback (a new signal), the loop continues with that signal at the same depth.
The function handles four failure modes:
- **Depth exceeded**: signal dropped, nil returned
- **Interrupt flag**: graceful shutdown, nil returned
- **Handler error**: caught by handler-case, logged, and depending on the sensor type and depth:
- Normal errors at low depth → memory rollback + retry as :loop-error
- :loop-error and :tool-error at any depth → dropped (avoids infinite retry loops)
- High-depth errors (depth > 2) → dropped (avoids cascading failures)
- **Unhandled error**: the handler-case catches everything, preventing any single bad signal from crashing the agent
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
;; Alias: process-signal → loop-process (backward compatibility)
(defun process-signal (signal)
(loop-process signal))
(defun loop-process (signal)
"The entry point to the Metabolic Pipeline: Perceive -> Reason -> Act."
(let ((current-signal signal))
(loop while current-signal do
(let ((depth (getf current-signal :depth 0))
(meta (getf current-signal :meta)))
(when (> depth 10)
(log-message "METABOLISM ERROR: Max recursion depth reached.")
(return nil))
(when (bt:with-lock-held (*loop-interrupt-lock*) *interrupt-flag*)
(log-message "METABOLISM: Interrupted by shutdown signal.")
(return nil))
(handler-case
(progn
(setf current-signal (perceive-gate current-signal))
(setf current-signal (reason-gate current-signal))
(let ((feedback (act-gate current-signal)))
(if feedback
(progn
(unless (getf feedback :meta) (setf (getf feedback :meta) meta))
(setf current-signal feedback))
(setf current-signal nil))))
(error (c)
(let ((sensor (ignore-errors (getf (getf current-signal :payload) :sensor))))
(log-message "METABOLISM CRASH [~a]: ~a" (or sensor :unknown) c)
(unless (member sensor '(:loop-error :tool-error :syntax-error))
(log-message "CRITICAL ERROR: Initiating Micro-Rollback.")
(rollback-memory 0))
(if (or (> depth 2) (member sensor '(:loop-error :tool-error)))
(setf current-signal nil)
(setf current-signal
(list :type :EVENT :depth (1+ depth) :meta meta
:payload (list :sensor :loop-error :message (format nil "~a" c) :depth depth)))))))))))
#+end_src
** Heartbeat Mechanism
The heartbeat is a background thread that fires every N seconds (configurable via ~HEARTBEAT_INTERVAL~ env var, default 60). On each tick, it:
1. Increments the save counter and saves memory to disk when the counter exceeds the auto-save interval (default 300s)
2. Injects a ~:heartbeat~ signal into the pipeline
The heartbeat signal is how background skills (Gardener, Scribe) get triggered without user input. These skills have triggers that match ~:sensor :heartbeat~ and run maintenance tasks during idle cycles.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *memory-auto-save-interval* 300)
#+end_src
** *heartbeat-save-counter*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *heartbeat-save-counter* 0)
#+end_src
** heartbeat-start
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun heartbeat-start ()
"Starts the background heartbeat thread."
(let ((interval (or (ignore-errors (parse-integer (uiop:getenv "HEARTBEAT_INTERVAL"))) 60))
(auto-save (or (ignore-errors (parse-integer (uiop:getenv "MEMORY_AUTO_SAVE_INTERVAL"))) *memory-auto-save-interval*)))
(setf *memory-auto-save-interval* auto-save)
(setf *heartbeat-save-counter* 0)
(setf *heartbeat-thread*
(bt:make-thread
(lambda ()
(loop
(sleep interval)
(incf *heartbeat-save-counter*)
(when (>= *heartbeat-save-counter* (/ *memory-auto-save-interval* interval))
(setf *heartbeat-save-counter* 0)
(save-memory-to-disk))
(inject-stimulus
(list :type :EVENT :payload (list :sensor :heartbeat :unix-time (get-universal-time))))))
:name "passepartout-heartbeat"))))
#+end_src
#+end_src
** Shutdown Save Flag
Controls whether memory is saved on shutdown. Useful for testing when you want a clean state on next boot.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *shutdown-save-enabled* t)
#+end_src
** System Health Status
Used by the health check protocol and the daemon's status endpoint. Set by ~diagnostics-startup-run~ during boot.
- ~:healthy~ — all checks passed
- ~:degraded~ — checks found issues but the daemon can still run
- ~:unhealthy~ — checks failed, the daemon may not function correctly
- ~:unknown~ — health check hasn't run yet
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *system-health* :unknown
"Current system health status: :healthy, :degraded, :unhealthy, or :unknown.")
#+end_src
** *health-check-ran*
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defvar *health-check-ran* nil
"Flag indicating if initial health check has completed.")
#+end_src
#+end_src
** Proactive Doctor
Runs the doctor diagnostics automatically at startup. If the doctor finds issues (missing dependencies, misconfigured providers), it prints a diagnostic message but does NOT block the daemon from starting. The user can see the issues and run ~passepartout doctor --fix~ to repair.
This is the "fail open" principle applied to boot: the system should start even with problems, not refuse to start until everything is perfect.
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun diagnostics-startup-run ()
"Runs the doctor diagnostics on startup. Returns health status."
(format t "~%")
(format t "==================================================~%")
(format t " DOCTOR: Running Startup Health Check~%")
(format t "==================================================~%")
(handler-case
(progn
(when (fboundp 'doctor-run-all)
(let ((result (doctor-run-all :auto-install nil)))
(setf *health-check-ran* t)
(if result
(progn
(setf *system-health* :healthy)
(format t "DAEMON: Health check passed. Starting services.~%"))
(progn
(setf *system-health* :degraded)
(format t "DAEMON: Health check found issues.~%")
(format t " Run 'passepartout doctor --fix' to repair.~%")))))
(setf *health-check-ran* t))
(error (c)
(format t "DOCTOR ERROR: ~a~%" c)
(setf *system-health* :unhealthy)
(setf *health-check-ran* t)))
(format t "==================================================~%~%"))
#+end_src
** Main Entry Point (main)
The top-level entry point. Called by ~passepartout daemon~ and ~passepartout tui~.
Boot sequence:
1. Load environment variables from ~.config/passepartout/.env~
2. Load persisted memory state from disk
3. Register core actuators (:system, :tool, :tui)
4. Initialize all skills (tangging .lisp or loading from XDG)
5. Run the proactive health check
6. Start the heartbeat thread (background maintenance)
7. Start the TCP daemon (listens for CLI/TUI connections)
8. Install the SIGINT handler (graceful shutdown on Ctrl+C)
9. Enter the idle sleep loop (wakes on interrupt)
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun main ()
"Entry point for Passepartout. Initializes the system and enters idle loop."
(let* ((home (uiop:getenv "HOME"))
(env-file (uiop:merge-pathnames* ".config/passepartout/.env" (uiop:ensure-directory-pathname home))))
(when (uiop:file-exists-p env-file)
(cl-dotenv:load-env env-file)))
(load-memory-from-disk)
(actuator-initialize)
(skill-initialize-all)
;; Run proactive doctor before starting services
(diagnostics-startup-run)
(heartbeat-start)
(start-daemon)
#+sbcl
(sb-sys:enable-interrupt sb-unix:sigint
(lambda (sig code scp)
(declare (ignore sig code scp))
(log-message "SHUTDOWN: SIGINT received. Saving memory...")
(when *shutdown-save-enabled* (save-memory-to-disk))
(uiop:quit 0)))
(let ((sleep-interval (or (ignore-errors (parse-integer (uiop:getenv "DAEMON_SLEEP_INTERVAL"))) 3600)))
(loop
(when (bt:with-lock-held (*loop-interrupt-lock*) *interrupt-flag*)
(log-message "SHUTDOWN: Interrupt flag set. Saving memory...")
(when *shutdown-save-enabled* (save-memory-to-disk))
(return))
(sleep sleep-interval))))
#+end_src
* Test Suite
Verifies that the immune system (error handling) correctly catches and reports errors from the cognitive pipeline.
#+begin_src lisp :tangle ../lisp/core-loop.lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :passepartout-immune-system-tests
(:use :cl :fiveam :passepartout)
(:export #:immune-suite))
(in-package :passepartout-immune-system-tests)
(def-suite immune-suite :description "Verification of the Immune System (Core Error Hooks)")
(in-suite immune-suite)
(test loop-error-injection
"Verify that a crash in think/decide triggers a :loop-error stimulus."
(clrhash passepartout::*skills-registry*)
(passepartout:defskill :evil-skill
:priority 100
:trigger (lambda (ctx) (eq (getf (getf ctx :payload) :sensor) :user-input))
:probabilistic (lambda (ctx) (declare (ignore ctx)) (error "CRITICAL BRAIN FAILURE"))
:deterministic nil)
(passepartout:loop-process '(:type :EVENT :payload (:sensor :user-input)))
(let ((logs (passepartout:context-get-system-logs 20)))
(is (not (null (find-if (lambda (line) (search "CRITICAL BRAIN FAILURE" line)) logs))))))
#+end_src