Skip to content
2 changes: 1 addition & 1 deletion TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ You can also pass the original human-readable name; `normalize_value()` canonica

3. **Handwrite a flattened version** of the stock profile. Open the stock JSON, manually copy every field from the inherited parent into the child JSON, drop the `inherits` field. Use `examples/profiles/community_merged_*.json` as the shape reference. Save in `profiles/user/`.

After v1.4.2 the workflow **surfaces** `slice.metadata.print_settings_id` in `slice_summary.txt` (next to the gcode); the **agent** (or you, if you're driving the CLI) should inspect it and compare against the preset you requested. If the names don't match, treat the slice as authoritative about what Orca actually did — Orca silently fell back to a different profile.
The workflow **surfaces** `slice.metadata.print_settings_id` in the slice result, and it is stamped into the gcode's own metadata next to the file; the **agent** (or you, if you're driving the CLI) should inspect it and compare against the preset you requested. If the names don't match, treat the slice as authoritative about what Orca actually did; it silently fell back to a different profile.

### `warning` event with `kind:"no_supports_variant"`

Expand Down
63 changes: 35 additions & 28 deletions adapters/hermes/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,16 +473,18 @@ def main(argv=None) -> int:
return 0

# Install.
# Pre-flight (read-only): verify the run.py anchor BEFORE copying any
# files. Hermes auto-imports tools/*, so copying first and then failing
# the anchor check would leave a partial install — the form tool would
# register while run.py stays unwired.
# Pre-flight (read-only): note whether the run.py FALLBACK anchor is
# present. It no longer gates the install. The u1-form plugin publishes
# the form callback itself from its pre_gateway_dispatch hook (the primary,
# upgrade-durable path), so a missing anchor only means the belt-and-
# suspenders run.py patch is skipped, not a broken form.
run_txt = run_py.read_text()
if RUN_PY_MARKER not in run_txt and RUN_PY_ANCHOR not in run_txt:
print(f"ERROR: anchor {RUN_PY_ANCHOR!r} not found in gateway/run.py.")
print(" Hermes may have changed the clarify wiring layout. Aborting")
print(" before copying anything — no files were modified.")
return 2
_run_anchor_present = (RUN_PY_MARKER in run_txt or RUN_PY_ANCHOR in run_txt)
if not _run_anchor_present:
print(f"note: run.py anchor {RUN_PY_ANCHOR!r} not found; skipping the")
print(" optional run.py fallback patch. The u1-form plugin publishes")
print(" the form callback itself, so the form still works.")
print()

print("[1/6] copy tools/ (form_gateway) + remove pre-plugin layout files")
for name, src in tools_src.items():
Expand Down Expand Up @@ -528,22 +530,25 @@ def main(argv=None) -> int:
print(f" {_install_hook_plugin(venv_python, hook_plugin_src, dry_run=a.dry_run)}")

print()
print("[5/6] patch gateway/run.py (anchor-based, marker-guarded)")
status = _patch_run_py(run_py, dry_run=a.dry_run)
print(f" {status}: {run_py}")
if status == "anchor-not-found":
# Unreachable in practice (pre-flight above checks the same strings),
# kept as a belt-and-braces guard against races.
print()
print(f" ERROR: anchor {RUN_PY_ANCHOR!r} not found in gateway/run.py.")
print(" Hermes may have changed the clarify wiring layout. Aborting.")
return 2
if status == "malformed-block":
print()
print(" ERROR: found the u1 begin marker without its end marker in run.py.")
print(" Refusing to edit a half-present block — inspect run.py, then")
print(" restore the .u1-bak backup or remove the block manually.")
return 2
print("[5/6] patch gateway/run.py (OPTIONAL fallback; anchor-based, marker-guarded)")
if not _run_anchor_present:
print(" skipped: no anchor in this Hermes build; the plugin publish covers it")
else:
status = _patch_run_py(run_py, dry_run=a.dry_run)
print(f" {status}: {run_py}")
if status == "anchor-not-found":
# Anchor vanished between pre-flight and here (a race). Non-fatal:
# the plugin's pre_gateway_dispatch publish carries the form.
print(" note: anchor vanished since pre-flight; skipping the fallback")
print(" patch. The u1-form plugin publish covers the form.")
elif status == "malformed-block":
# A half-present prior block (begin marker, no end). _patch_run_py
# refused to write it. Non-fatal now that the plugin carries the
# form; flag it so the operator can tidy run.py.
print(" WARNING: found the u1 begin marker without its end marker in")
print(" run.py; left it untouched. Inspect run.py and restore")
print(" the .u1-bak backup or remove the block manually. The")
print(" plugin publish still carries the form regardless.")

if a.dry_run:
print()
Expand All @@ -556,9 +561,11 @@ def main(argv=None) -> int:
print(" ", _verify_hook_plugin(venv_python))

print()
print("Done. Restart the Hermes gateway so both plugins load and the patched")
print("gateway/run.py takes effect. First inbound Telegram message installs")
print("send_form on the live adapter class (watch for the")
print("Done. Restart the Hermes gateway so the plugins load (and any run.py")
print("fallback patch takes effect). Each inbound Telegram message installs")
print("send_form on the live adapter class AND publishes the form callback")
print("from the plugin, so the form keeps working across Hermes upgrades even")
print("if the run.py fallback is absent (watch for the")
print("'u1-form: TelegramAdapter.send_form installed' log line).")
return 0

Expand Down
137 changes: 130 additions & 7 deletions adapters/hermes/plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,29 @@
hook that patches ``send_form`` onto the LIVE Telegram adapter class.
* ``telegram_patch.py`` — the class-level patch (inline-keyboard
renderer, callback router, answers-file writer).
* The gateway's ``run.py`` anchor patch (applied by ``install.py``)
publishes a per-turn form callback into ``tools.form_gateway`` keyed
by ``agent.session_id`` — the same value Hermes' registry dispatch
passes to tool handlers. That bridge exists because generic dispatch
hands handlers only (task_id, session_id, user_task): there is no
callback kwarg and no agent reference, so an ``agent.form_callback``
attribute alone is unreachable from a registered tool.
* The per-turn form callback published into ``tools.form_gateway``.
A registered tool cannot reach it any other way: generic dispatch
hands handlers only (task_id, session_id, user_task), with no callback
kwarg and no agent reference, so an ``agent.form_callback`` attribute
alone is unreachable. Two publishers, primary + fallback:
1. PRIMARY (upgrade-durable): ``_pre_gateway_dispatch`` builds the
callback from its own context (live adapter + inbound chat_id +
gateway loop) and publishes it every inbound message. The plugin
lives on the persistent volume, so this survives a Hermes package
upgrade that replaces ``gateway/run.py``.
2. FALLBACK (older Hermes): the ``run.py`` anchor patch applied by
``install.py`` publishes the same callback from the gateway's
per-turn locals. Kept for Hermes builds whose dispatch context is
thinner than the primary path needs. Last-writer-wins per turn;
both build an equivalent callback, so they coexist safely.
Publishing only renders the operator FORM; it is not the print-start
boundary (that stays in ``u1_print_start_gate.py``, untouched), so
where the callback is wired has no bearing on model-free confirm.
"""

from __future__ import annotations

import asyncio
import json
import logging
from typing import Any, Callable, Dict, Optional
Expand Down Expand Up @@ -196,6 +208,104 @@ def _form_handler(args: Dict[str, Any], **kwargs: Any) -> str:
# fires before agent dispatch on every inbound message, so send_form exists
# before any form callback can run.

def _acquire_loop():
"""The gateway event loop, captured while we are ON it.

``pre_gateway_dispatch`` runs synchronously inside the gateway's async
dispatch coroutine, so ``get_running_loop()`` returns the loop the
adapter's ``send_form`` coroutine must be scheduled on. Returns None when
no loop runs in this thread (e.g. unit tests, or a future Hermes that
dispatches hooks off-loop) so the caller skips the plugin publish and
lets the run.py fallback carry the turn."""
try:
return asyncio.get_running_loop()
except RuntimeError:
return None


def _make_form_callback(adapter, chat_id, session_key, loop):
"""Build the per-turn form callback the u1_kit / form tool invokes.

Same contract as the gateway run.py patch's ``_form_callback_sync``, but
sourced from the plugin's own dispatch context so it survives a Hermes
upgrade that replaces run.py: render ``form_schema`` via the patched
adapter's ``send_form``, block on the form_gateway primitive, return the
answer dict. Fail-soft: every failure path returns an ``{"_error": ...}``
dict (the driving tool surfaces it) and never raises."""
def _callback(form_schema):
import uuid
from tools import form_gateway as _fmod
if not hasattr(adapter, "send_form"):
return {"_error": "active adapter has no send_form (plugin not loaded?)"}
form_id = uuid.uuid4().hex[:10]
_fmod.register(form_id, session_key or "", form_schema)
try:
adapter.pause_typing_for_chat(chat_id)
except Exception:
pass
try:
fut = asyncio.run_coroutine_threadsafe(
adapter.send_form(
chat_id=chat_id, form_schema=form_schema,
form_id=form_id, session_key=session_key or "",
metadata=None,
),
loop,
)
except Exception as exc:
_fmod.clear_session(session_key or "")
return {"_error": f"form prompt could not be scheduled: {exc}"}
try:
send_result = fut.result(timeout=15)
if not getattr(send_result, "success", False):
_fmod.clear_session(session_key or "")
return {"_error": "form prompt send failed"}
except Exception as exc:
logger.warning("u1-form: form send failed: %s", exc)
_fmod.clear_session(session_key or "")
return {"_error": f"form send exception: {exc}"}
response = _fmod.wait_for_response(
form_id, timeout=float(_fmod.get_form_timeout()))
if response is None:
return {"_timeout": True}
return response
return _callback


def _publish_form_callback(adapter, event, session_store) -> bool:
"""Publish a per-turn form callback into tools.form_gateway from the
plugin's own dispatch context (the upgrade-durable primary path).

Best-effort and fail-soft: any missing ingredient (no addressable chat,
not on the gateway loop, form_gateway absent) just returns False and
leaves the run.py fallback, where present, to carry the turn. Returns
True when a callback was published."""
source = getattr(event, "source", None)
chat_id = getattr(source, "chat_id", None)
if source is None or chat_id is None:
return False # internal / None event; no chat to address
loop = _acquire_loop()
if loop is None:
return False # not on the gateway loop; run.py fallback carries it
try:
from tools import form_gateway
except ImportError:
return False
session_key = ""
try:
gen = getattr(session_store, "_generate_session_key", None)
if gen is not None:
session_key = gen(source) or ""
except Exception:
# A private-API drift just means we key on "" -> form_gateway's
# __default__ (latest registration) resolves it for a single
# operator, exactly as the run.py path already relies on.
session_key = ""
form_gateway.set_form_callback(
session_key, _make_form_callback(adapter, chat_id, session_key, loop))
return True


def _pre_gateway_dispatch(**kwargs: Any) -> None:
try:
from . import telegram_patch
Expand Down Expand Up @@ -223,6 +333,19 @@ def _pre_gateway_dispatch(**kwargs: Any) -> None:
except Exception:
logger.warning("u1-form: proactive callback-handler "
"registration failed", exc_info=True)
# PRIMARY form-callback publish (upgrade-durable). Builds
# the callback from this dispatch context and publishes it
# into form_gateway so the form works even when a Hermes
# upgrade has wiped the run.py fallback patch. Fail-soft:
# a False return (no chat / off-loop) just leaves the
# run.py fallback to carry the turn where it is present.
try:
_publish_form_callback(
adapter, kwargs.get("event"),
kwargs.get("session_store"))
except Exception:
logger.warning("u1-form: form-callback publish failed",
exc_info=True)
except Exception:
logger.warning("u1-form: pre_gateway_dispatch patch attempt failed",
exc_info=True)
Expand Down
57 changes: 3 additions & 54 deletions adapters/hermes/tools/form_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@
1. **Native UI** (button-driven): adapter renders the schema as inline
keyboards, handles taps in-place, and on Submit calls
``resolve_gateway_form(form_id, answer_dict)`` to unblock the agent.
2. **Text fallback**: adapter shows the schema's ``text_fallback`` field
and the user types the one-line answer; the gateway's
``_handle_message`` intercept resolves with the typed line (sets
``answer = {"_text": "<line>"}``); the form tool can route through
``u1_form.parse_answers`` itself.
2. **Text fallback**: the schema also carries a ``text_fallback`` field so
an adapter without native UI can present a typed one-line path to the
same answer.

Module-level state (same shape as ``clarify_gateway``) so platform adapters
call ``resolve_gateway_form`` without holding a back-reference to the
Expand Down Expand Up @@ -49,15 +47,6 @@ class _FormEntry:
schema: Dict[str, Any]
event: threading.Event = field(default_factory=threading.Event)
response: Optional[Dict[str, Any]] = None
awaiting_text: bool = False # set when adapter falls back to text intake

def signature(self) -> Dict[str, object]:
return {
"form_id": self.form_id,
"session_key": self.session_key,
"schema_version": self.schema.get("version"),
"fields": [f.get("id") for f in self.schema.get("fields", [])],
}


_lock = threading.RLock()
Expand Down Expand Up @@ -151,41 +140,6 @@ def cancel_gateway_form(form_id: str) -> bool:
return resolve_gateway_form(form_id, {"_cancelled": True})


def get_pending_for_session(session_key: str) -> Optional[_FormEntry]:
"""Return the OLDEST pending form entry for a session, or None.

Used by the text-fallback intercept in ``_handle_message`` — when a form
is awaiting a free-form text response (operator typed instead of
tapping), the next user message in that session is captured.
"""
with _lock:
ids = _session_index.get(session_key) or []
for fid in ids:
entry = _entries.get(fid)
if entry is None:
continue
if entry.awaiting_text:
return entry
return None


def mark_awaiting_text(form_id: str) -> bool:
"""Flip an entry into text-capture mode (operator typed instead of tapping)."""
with _lock:
entry = _entries.get(form_id)
if entry is None:
return False
entry.awaiting_text = True
return True


def has_pending(session_key: str) -> bool:
"""True when this session has at least one pending form entry."""
with _lock:
ids = _session_index.get(session_key) or []
return any(_entries.get(fid) is not None for fid in ids)


def clear_session(session_key: str) -> int:
"""Drop every pending form for a session (e.g. on session boundary).

Expand Down Expand Up @@ -280,11 +234,6 @@ def get_notify(session_id: str = "") -> Optional[Any]:
return get_form_callback(session_id)


def set_notify(session_id: str, callback: Any) -> None:
"""Publish the form callback for a session. Alias of set_form_callback."""
set_form_callback(session_id, callback)


def invoke_form(session_id: str, form_schema: Dict[str, Any]) -> Dict[str, Any]:
"""Render ``form_schema`` to the operator and block until they submit,
returning the answer dict.
Expand Down
7 changes: 1 addition & 6 deletions adapters/hermes/tools/u1_kit_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,11 +395,6 @@ def u1_kit_tool(
return f"{_passthrough}\n{_summary}" if _passthrough else _summary


def check_u1_kit_requirements() -> bool:
"""u1_kit needs the workflow script at the expected deploy path."""
return Path(DEFAULT_WORKFLOW_SCRIPT).exists()


# =============================================================================
# Function-calling tool schema
# =============================================================================
Expand Down Expand Up @@ -448,4 +443,4 @@ def check_u1_kit_requirements() -> bool:
# a runtime-registered toolset can never satisfy). The u1-form plugin registers
# u1_kit as its own OFFERED toolset via ctx.register_tool (see
# adapters/hermes/plugin/__init__.py register()). This module just exposes the
# handler (u1_kit_tool) + schema (U1_KIT_SCHEMA) + check (check_u1_kit_requirements).
# handler (u1_kit_tool) + schema (U1_KIT_SCHEMA).
Loading
Loading