Files
passepartout/org/core-loop-act.org
Amr Gharbeia a77580c449
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 2s
fix: correct setf form in perceive gate HITL handler
(setf (getf signal :approved t)) → (setf (getf signal :approved) t)

Caught during system compilation. This is exactly the class of bug that
the REPL-first discipline would have caught instantly.
2026-05-03 13:19:04 -04:00

13 KiB

Stage 3: Act (act.lisp)

Overview: Architectural Intent

The Act stage is where cognition meets reality. After the Probabilistic engine proposes an action and the Deterministic engine verifies it, Act executes it through the appropriate actuator.

An actuator is a function that takes (action context) and performs a physical operation: send a message to the TUI, execute a shell command, call a Telegram API, write to a file. Actuators are registered in a global hash table (*actuator-registry*) and dispatched by name.

The key architectural choice: actuators are not privileged. The same dispatch mechanism that routes to :shell or :file also routes to :telegram or :signal. There is no special handling for dangerous actuators — safety is enforced at the Reason stage by the deterministic engine, not by Act. This means:

  1. Adding a new actuator requires no changes to the core — just register it
  2. Safety is centralized in the deterministic gates, not scattered across actuator implementations
  3. Every actuator benefits from the same security checks (the Bouncer, the Policy)

Why Dispatch-Action Verifies Again?

The Reason stage already ran every proposed action through the deterministic engine. So why does loop-gate-act call cognitive-verify again?

Because a skill's deterministic gate runs during Reason, but between Reason and Act, the action might have been transformed by the pipeline (metadata added, format normalized). The last-mile verification catches any transformation that might have introduced an unsafe property. It's the same philosophy as "trust but verify" — the second check is cheap and catches a class of bugs that would otherwise be silent data corruption.

Implementation

Package Context

(in-package :passepartout)

Actuator Configuration

*actuator-default* determines where actions go when no explicit target is specified. Defaults to :cli.

*actuator-silent* lists actuator targets that don't generate tool-output feedback. For example, sending a message to the CLI or Emacs doesn't need to produce a tool-output event — the user can see the message directly. This prevents redundant feedback loops.

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defvar *actuator-default* :cli
  "The actuator used when no explicit target is specified.")

actuator-silent

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defvar *actuator-silent* '(:cli :system-message :emacs)
  "List of actuators that don't generate tool-output feedback.")

actuator-initialize

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun actuator-initialize ()
  "Register core actuators and load configuration."
  (let ((def (uiop:getenv "DEFAULT_ACTUATOR"))
        (silent (uiop:getenv "SILENT_ACTUATORS")))
    (when def
      (setf *actuator-default* (intern (string-upcase def) :keyword)))
    (when silent
      (setf *actuator-silent*
            (mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space) s)) :keyword))
                    (uiop:split-string silent :separator '(#\,))))))

  (register-actuator :system #'action-system-execute)
  (register-actuator :tool #'action-tool-execute)

  (register-actuator :tui (lambda (action context)
                            (declare (ignore context))
                            (let* ((meta (getf action :meta))
                                   (stream (getf meta :reply-stream)))
                              (when (and stream (open-stream-p stream))
                                (format stream "~a" (frame-message action))
                                (finish-output stream))))))

#+end_src

Action Dispatch (action-dispatch)

Routes an approved action to its registered actuator. The target is resolved in priority order:

  1. The explicit :target field on the action
  2. The source of the original signal (reply to the sender)
  3. The default actuator (:cli)

Heartbeats are silently dropped here — they should never generate an actuation.

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun action-dispatch (action context)
  "Route an approved action to its registered actuator."
  (let ((payload (proto-get action :payload)))
    (when (eq (proto-get payload :sensor) :heartbeat)
      (return-from action-dispatch nil))

    (when (and action (listp action))
      (let* ((meta (proto-get context :meta))
             (source (proto-get meta :source))
             (raw-target (or (proto-get action :target) source *actuator-default*))
             (target (intern (string-upcase (string raw-target)) :keyword))
             (actuator-fn (gethash target *actuator-registry*)))
        (when (and meta (null (getf action :meta)))
          (setf (getf action :meta) meta))
        (if actuator-fn
            (funcall actuator-fn action context)
            (log-message "ACT ERROR: No actuator registered for '~s'" target))))))

System Actuator (action-system-execute)

Handles internal harness commands: :eval (execute arbitrary Lisp) and :message (log to the harness log). This is how the deterministic engine communicates results back to the user.

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun action-system-execute (action context)
  "Execute internal harness commands."
  (declare (ignore context))
  (let* ((payload (getf action :payload))
         (cmd (getf payload :action)))
    (case cmd
      (:eval
       (eval (read-from-string (getf payload :code))))
      (:message
       (log-message "ACT [System]: ~a" (getf payload :text)))
      (t
       (log-message "ACT ERROR [System]: Unknown command '~s'" cmd)))))

Tool Actuator (action-tool-execute)

Executes a registered cognitive tool. Cognitive tools are registered via def-cognitive-tool in the package.lisp and are the primary way the LLM interacts with the outside world.

The function handles:

  • Tool dispatch by name (case-insensitive lookup)
  • Argument normalization (if the arguments are nested in a list, they're flattened)
  • Result formatting (structured results are sent back to the source)
  • Error handling (tool errors produce :tool-error events, not crashes)

The tool's return value is packed into a :tool-output event and fed back into the pipeline, where it becomes the next perception. This is how the agent "sees" the result of its actions.

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun action-tool-execute (action context)
  "Execute a registered cognitive tool."
  (let* ((payload (getf action :payload))
         (tool-name (getf payload :tool))
         (tool-args (getf payload :args))
         (depth (getf context :depth 0))
         (meta (getf context :meta))
         (source (getf meta :source))
         (tool (gethash (string-downcase (string tool-name)) *cognitive-tool-registry*)))
    (if tool
        (handler-case
            (let* ((clean-args (if (and (listp tool-args) (listp (car tool-args))) (car tool-args) tool-args))
                   (result (funcall (cognitive-tool-body tool) clean-args)))
              (when source
                (action-dispatch (list :TYPE :REQUEST :TARGET source 
                                       :PAYLOAD (list :ACTION :MESSAGE :TEXT (tool-result-format tool-name result)))
                                context))
              (list :TYPE :EVENT :DEPTH (1+ depth) :META meta
                    :PAYLOAD (list :SENSOR :tool-output :RESULT result :TOOL tool-name)))
          (error (c)
            (list :TYPE :EVENT :DEPTH (1+ depth) :META meta
                  :PAYLOAD (list :SENSOR :tool-error :TOOL tool-name :MESSAGE (format nil "~a" c)))))
        (list :TYPE :EVENT :DEPTH (1+ depth) :META meta
              :PAYLOAD (list :SENSOR :tool-error :MESSAGE (format nil "Tool '~a' not found" tool-name))))))

Tool Result Formatting (tool-result-format)

Converts a tool's return value into a human-readable string for display to the user. Handles structured results (plists with :status, :content, :message) and plain values.

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun tool-result-format (tool-name result)
  "Format a tool result for display."
  (if (listp result)
      (let ((status (getf result :status))
            (content (getf result :content))
            (msg (getf result :message)))
        (cond
          ((and (eq status :success) content) (format nil "~a" content))
          ((and (eq status :error) msg) (format nil "ERROR [~a]: ~a" tool-name msg))
          (t (format nil "TOOL [~a] RESULT: ~s" tool-name result))))
      (format nil "TOOL [~a] RESULT: ~a" tool-name result)))

Act Gate (Stage 3)

The final stage of the metabolic pipeline. It receives a signal that has been reasoned (has an :approved-action) and dispatches it.

The gate runs a last-mile deterministic check on the approved action before execution. This catches any issues introduced during pipeline processing (e.g., metadata added by Perceive that changes the action's format).

After dispatch, the gate captures any feedback produced by the actuation (tool output, error events) and returns it to the loop for the next cognitive cycle.

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun loop-gate-act (signal)
  "Final stage of the metabolic pipeline: Actuation.
For approval-required actions, creates a Flight Plan instead of executing."
  (let* ((approved (getf signal :approved-action))
         (signal-status (getf signal :status))
         (type (getf signal :type))
         (meta (getf signal :meta))
         (source (getf meta :source))
         (feedback nil))
    ;; HITL: if the approved action requires human approval,
    ;; create a Flight Plan (Emacs) and HITL entry (all gateways).
    (when (and approved
               (eq (getf approved :level) :approval-required))
      (let* ((payload (getf approved :payload))
             (blocked-action (getf payload :action))
             (hitl (hitl-create blocked-action)))
        (log-message "ACT: Action requires approval — creating Flight Plan + HITL (~a)" (getf hitl :token))
        (dispatcher-flight-plan-create blocked-action)
        (setf (getf signal :status) :suspended)
        (action-dispatch (list :target source
                               :payload (list :text (getf hitl :message)))
                         signal)
        (setf approved nil)
        (setf feedback nil)))
    (when approved
      (let* ((original-type (getf approved :type))
             (verified (cognitive-verify approved signal)))
        (if (and (listp verified) (member (getf verified :type) '(:LOG :EVENT))
                 (not (eq (getf verified :level) :approval-required))
                 (not (member original-type '(:LOG :EVENT))))
            (progn
              (log-message "ACT BLOCKED: Action failed last-mile deterministic check.")
              (setf (getf signal :approved-action) nil)
              (setf feedback verified))
            (progn
              (setf (getf signal :approved-action) verified)
              (setf approved verified)))))

    (case type
      (:REQUEST (action-dispatch signal signal))
      (:LOG (action-dispatch signal signal))
      (:EVENT
       (if approved
           (let* ((target (getf approved :target))
                  (result (action-dispatch approved signal)))
             (cond
               ((and (listp result) (member (getf result :type) '(:EVENT :LOG)))
                (setf feedback result))
               ((and result (not (member target *actuator-silent*)))
                (setf feedback (list :type :EVENT :depth (1+ (getf signal :depth 0)) :meta meta
                                    :payload (list :sensor :tool-output :result result :tool approved))))))
           (when source (action-dispatch signal signal)))))
    (setf (getf signal :status) :acted)
    feedback))

Test Suite

Verifies that the act gate correctly processes an approved action and sets the signal status to :acted.

(eval-when (:compile-toplevel :load-toplevel :execute)
  (ql:quickload :fiveam :silent t))

(defpackage :passepartout-pipeline-act-tests
  (:use :cl :fiveam :passepartout)
  (:export #:pipeline-act-suite))

(in-package :passepartout-pipeline-act-tests)

(def-suite pipeline-act-suite :description "Test suite for Act pipeline")
(in-suite pipeline-act-suite)

(test test-loop-gate-act-basic
  (clrhash passepartout::*skill-registry*)
  (let* ((signal (list :type :EVENT :status nil :depth 0 :approved-action '(:target :cli :payload (:text "Hello"))))
          (result (loop-gate-act signal)))
    (is (eq :acted (getf signal :status)))
    (is (null result))))