Files
hermes-agent/evals/core_tool_deferral/worker.py
Siddharth Balyan 5eea87882a Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder

Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.

* feat(clarify): one questions[] shape with per-question status and one outcome

The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.

Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.

The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.

* feat(clarify): every surface asks through the one question card

CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.

Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.

Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.

Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.

* fix(clarify): cancelled for a released messaging card, labels win over the skip word

A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.

A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.

The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.

* fix(tui): re-editing a multi-select answer keeps its picks

The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.

Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.

* test(clarify): align three checks with the one-answer confirm and the regenerated keys

The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.

* test(tui): the one-question card header is singular
2026-09-29 18:33:44 +05:30

371 lines
15 KiB
Python

#!/usr/bin/env python3
"""Run ONE (arm, model, task, rep) cell of the PR #97979 A/B in an isolated process.
Usage: worker.py <arm:base|pr> <model_slug> <task_id> <rep> <out_json>
Env: OPENROUTER_API_KEY must be set. Exit 3 = infra/config error (do not score).
"""
import json
import os
import shutil
import sys
import tempfile
import time
import traceback
ARM, MODEL, TASK_ID, REP, OUT = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), sys.argv[5]
# Arm trees: plain checkouts of the two SHAs under test (git worktree/clone —
# NEVER `pip install -e .` from them). Set both env vars before running:
# ABDEFER_BASE_TREE=/path/to/checkout-of-baseline-sha
# ABDEFER_PR_TREE=/path/to/checkout-of-pr-sha
TREE = os.environ.get(f"ABDEFER_{ARM.upper()}_TREE") or ""
if not TREE or not os.path.isdir(TREE):
print(f"ABORT: ABDEFER_{ARM.upper()}_TREE not set or not a directory", file=sys.stderr)
sys.exit(3)
HARNESS = os.path.dirname(os.path.abspath(__file__))
if not os.environ.get("OPENROUTER_API_KEY"):
print("ABORT: OPENROUTER_API_KEY missing", file=sys.stderr)
sys.exit(3)
# --- hermetic env BEFORE any hermes import -------------------------------
for var in list(os.environ):
if var.endswith(("_API_KEY", "_TOKEN")) and var != "OPENROUTER_API_KEY":
os.environ.pop(var, None)
os.environ.pop("FAL_KEY", None)
os.environ.pop("HERMES_PROFILE", None)
tmp_root = tempfile.mkdtemp(prefix=f"ab-{ARM}-{TASK_ID}-")
hermes_home = os.path.join(tmp_root, ".hermes")
workspace = os.path.join(tmp_root, "ws")
os.makedirs(hermes_home)
os.makedirs(workspace)
with open(os.path.join(hermes_home, "config.yaml"), "w", encoding="utf-8") as f:
f.write("model:\n provider: openrouter\n model: %s\n" % MODEL)
os.environ["HERMES_HOME"] = hermes_home
os.environ["TERMINAL_CWD"] = workspace
os.chdir(workspace)
sys.path.insert(0, HARNESS)
sys.path.insert(0, TREE)
import tasks as taskmod # noqa: E402
TASK = taskmod.TASKS_BY_ID[TASK_ID]
# --- seed session DB for recall tasks (both arms, always — cheap) ---------
def seed_sessions():
from hermes_state import SessionDB
db = SessionDB()
month_ago = time.time() - 30 * 86400
def sess(sid, msgs, t0):
db.create_session(sid, source="cli")
t = t0
for role, content in msgs:
db.append_message(sid, role, content=content, timestamp=t)
t += 60
sess("seed_shadow_vet", [
("user", "Back from the vet with Shadow. They put him on carprofen for the leg inflammation."),
("assistant", "Got it — what dose did they prescribe for Shadow?"),
("user", "Shadow's carprofen dose is 12.5 mg, twice a day with food. Two week course."),
("assistant", "Noted: Shadow takes 12.5 mg carprofen twice daily with food, for two weeks."),
], month_ago)
sess("seed_backup_talk", [
("user", "I keep worrying about my repos. The sparks-data repo really needs nightly backups, it has irreplaceable training data."),
("assistant", "Agreed — sparks-data should get a nightly backup job. The toybox repo is scratch space so it can be skipped."),
("user", "Right, toybox doesn't matter. Just sparks-data."),
], month_ago + 3 * 86400)
sess("seed_decoy_cat", [
("user", "My cat Biscuit is on 5 mg cetirizine for allergies."),
("assistant", "Noted — Biscuit: 5 mg cetirizine daily."),
], month_ago + 5 * 86400)
sess("seed_decoy_dose", [
("user", "I bumped the server worker count from 8 to 25 mg— sorry, to 25 workers. Typo."),
("assistant", "25 workers, got it."),
], month_ago + 6 * 86400)
db.close()
seed_sessions()
if TASK.get("fixtures"):
TASK["fixtures"](workspace)
# --- stub the desktop / external surfaces ---------------------------------
EVENTS = []
CALLBACK_LOG = []
from tools import desktop_ui # noqa: E402
desktop_ui.set_emitter(lambda sid, event, payload: EVENTS.append(
{"sid": sid, "event": event, "payload": payload}))
FOCUSED = taskmod.FOCUSED_APP
PREVIEW_TITLE = taskmod.PREVIEW_TITLE
TERMINAL_TAIL = taskmod.TERMINAL_TAIL
WINDOW_BELOW = taskmod.WINDOW_BELOW_TEXT
IMG_URL = taskmod.IMG_URL
_clarify_answers = list(TASK.get("clarify_answers") or [])
def clarify_cb(questions):
answers = {}
for entry in questions:
CALLBACK_LOG.append({"name": "clarify", "question": entry["question"], "choices": entry["choices"]})
ans = _clarify_answers.pop(0) if _clarify_answers else "Use your best judgement."
match = next((str(c) for c in entry["choices"] or [] if ans.lower() in str(c).lower()), None)
answers[entry["qid"]] = match or ans
return {"answers": answers, "outcome": "submitted"}
def tour_cb(payload):
CALLBACK_LOG.append({"name": "tour", "payload": payload})
action = payload.get("action", "")
if action == "targets":
return json.dumps({"success": True, "targets": [
{"selector": "[data-tour='settings']", "label": "Settings button", "stable": True},
{"selector": "[data-tour='composer']", "label": "Message composer", "stable": True},
{"selector": "[data-tour='sidebar']", "label": "Session sidebar", "stable": True},
{"selector": "[data-tour='model-picker']", "label": "Model picker", "stable": True},
]})
if action in ("start", "steps", "show"):
return json.dumps({"success": True, "shown": True,
"steps_total": len(payload.get("steps") or []) or 1,
"completed": True})
return json.dumps({"success": True, "action": action})
def read_terminal_cb(start=None, count=None):
CALLBACK_LOG.append({"name": "read_terminal", "start": start, "count": count})
lines = ["$ make build", "compiling core...", "linking...", TERMINAL_TAIL]
return json.dumps({"total_lines": 4, "start": 0, "end": 3,
"viewport_rows": 24, "cursor_row": 3,
"text": "\n".join(lines)})
def read_preview_cb(start=None, count=None):
CALLBACK_LOG.append({"name": "read_preview", "start": start, "count": count})
return json.dumps({"title": PREVIEW_TITLE, "url": "https://example.com/docs/",
"text": ("Example Domain\nThis domain is for use in documents.\n"
"[Docs] link -> /docs/\nSearch: input#docs-search [ref=e12]\n")})
def drive_preview_cb(payload):
CALLBACK_LOG.append({"name": "drive_preview", "payload": payload})
action = payload.get("action", "")
if "annotate" in json.dumps(payload) or action in ("highlight", "point", "underline", "clear", "hold"):
return json.dumps({"success": True, "annotated": payload.get("selector") or payload.get("ref")})
if action in ("click", "goto", "navigate"):
return json.dumps({"success": True, "title": PREVIEW_TITLE,
"url": "https://example.com/docs/",
"text": "Docs index. Search box: input#docs-search [ref=e12]"})
if action in ("snapshot", "read", "links"):
return json.dumps({"success": True, "title": PREVIEW_TITLE,
"url": "https://example.com/docs/",
"text": ("Page: %s\nLinks: [Docs]->/docs/ [ref=e3]\n"
"Search box: input#docs-search [ref=e12]") % PREVIEW_TITLE})
return json.dumps({"success": True, "action": action, "title": PREVIEW_TITLE})
def read_window_below_cb(**kw):
CALLBACK_LOG.append({"name": "read_window_below", "kw": kw})
return json.dumps({"title": "Invoices — draft", "text": WINDOW_BELOW})
def connection_cb(payload):
# Answer every manage_connections MCP target as installed.
CALLBACK_LOG.append({"name": "manage_connections", "targets": payload.get("targets", [])})
return json.dumps({"settled_by": "all_resolved", "targets": [
{"name": t["name"], "status": "installed"} for t in payload.get("targets", [])]})
# --- import the tree's model_tools + patch registry stubs ------------------
import model_tools # noqa: E402 (triggers registrations + plugin discovery)
from tools.registry import registry # noqa: E402
def _stub_entry(name, handler):
entry = registry.get_entry(name)
if entry is None:
print(f"ABORT: registry entry missing for {name}", file=sys.stderr)
sys.exit(3)
entry.handler = handler
entry.check_fn = None
entry.is_async = False
def computer_use_stub(args, **kw):
CALLBACK_LOG.append({"name": "computer_use", "args": args})
action = (args or {}).get("action", "screenshot")
shot = os.path.join(tmp_root, "screen.png")
with open(shot, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\nstub")
return json.dumps({
"success": True, "action": action, "screenshot": shot,
"analysis": ("Focused window: %s. It shows a note titled 'Shadow feeding "
"schedule' with a table of meal times. No error dialogs visible." % FOCUSED),
})
def image_generate_stub(args, **kw):
CALLBACK_LOG.append({"name": "image_generate", "args": args})
return json.dumps({"success": True, "image": IMG_URL,
"prompt_used": (args or {}).get("prompt", "")})
_stub_entry("computer_use", computer_use_stub)
_stub_entry("image_generate", image_generate_stub)
# --- build agent -----------------------------------------------------------
TOOLSETS = ["file", "terminal", "search", "web", "todo", "session_search",
"clarify", "image_gen", "computer_use", "cronjob", "memory",
"desktop_ui", "project", "code_execution"]
from run_agent import AIAgent # noqa: E402
agent = AIAgent(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
provider="openrouter",
model=MODEL,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
skip_background_review=True,
enabled_toolsets=TOOLSETS,
max_iterations=40,
clarify_callback=clarify_cb,
tour_callback=tour_cb,
read_terminal_callback=read_terminal_cb,
read_preview_callback=read_preview_cb,
drive_preview_callback=drive_preview_cb,
read_window_below_callback=read_window_below_cb,
connection_callback=connection_cb,
)
PREAMBLE = ("You are running inside the Hermes desktop app on the user's machine. "
"Your working directory (the workspace) is: %s\n\nTask: " % workspace)
t0 = time.time()
error = None
convo = None
user_roundtrips = 0
try:
convo = agent.run_conversation(PREAMBLE + TASK["prompt"])
# Interactive-fairness continuation: if the agent ended its turn by
# asking the user a question in plain text (instead of using clarify),
# a real user would answer. Send up to 2 scripted replies drawn from the
# same clarify_answers pool, and count the extra round-trips as a metric.
for _ in range(2):
_msgs = (convo or {}).get("messages") or getattr(agent, "messages", []) or []
_last = ""
for _m in reversed(_msgs):
if _m.get("role") == "assistant" and (_m.get("content") or "").strip():
_last = _m["content"].strip()
break
if "?" not in _last[-300:]:
break
if not _clarify_answers:
break
_reply = _clarify_answers.pop(0)
user_roundtrips += 1
convo = agent.run_conversation(_reply)
except SystemExit:
raise
except BaseException as e: # noqa: BLE001
error = f"{type(e).__name__}: {e}"
traceback.print_exc()
wall = time.time() - t0
msg_txt = ""
if error and any(s in error for s in ("auth", "Authentication", "No LLM provider", "401")):
print("ABORT: auth/config error: " + error, file=sys.stderr)
sys.exit(3)
messages = (convo or {}).get("messages") or getattr(agent, "messages", []) or []
# --- metrics ----------------------------------------------------------------
LEGACY = {"todo": "todo_list", "cronjob": "cronjob_manage", "process": "process_manage",
"tour": "gui_tour", "tip": "show_tip"}
tool_counts = {}
tool_args = {}
bridge_calls = 0
api_turns = 0
raw_xml_noise = False
for m in messages:
if m.get("role") == "assistant":
api_turns += 1
if "<function=" in (m.get("content") or ""):
raw_xml_noise = True
for tc in (m.get("tool_calls") or []):
fn = tc.get("function", {})
name = fn.get("name", "")
try:
fargs = json.loads(fn.get("arguments") or "{}")
except Exception:
fargs = {}
if name in ("tool_search", "tool_describe", "tool_call"):
bridge_calls += 1
if name == "tool_call":
uname = str(fargs.get("name") or "")
uargs = fargs.get("arguments") or {}
if isinstance(uargs, str):
try:
uargs = json.loads(uargs)
except Exception:
uargs = {}
uname = LEGACY.get(uname, uname)
if uname:
tool_counts[uname] = tool_counts.get(uname, 0) + 1
tool_args.setdefault(uname, []).append(uargs)
continue
cname = LEGACY.get(name, name)
tool_counts[cname] = tool_counts.get(cname, 0) + 1
tool_args.setdefault(cname, []).append(fargs)
final_answer = ""
for m in reversed(messages):
if m.get("role") == "assistant" and (m.get("content") or "").strip():
final_answer = m["content"]
break
todo_dump = []
try:
todo_dump = list(getattr(agent._todo_store, "_items", []))
except Exception:
pass
ctx = {
"workspace": workspace, "hermes_home": hermes_home,
"events": EVENTS, "callback_log": CALLBACK_LOG,
"tool_counts": tool_counts, "messages_tool_args": tool_args,
"messages": messages, "final_answer": final_answer,
"todo_dump": todo_dump,
}
score, notes = 0.0, ["run errored: %s" % error] if error else (0.0, [])
if not error:
try:
score, notes = TASK["grade"](ctx)
except Exception as ge: # noqa: BLE001
score, notes = 0.0, [f"grader crashed: {ge}"]
else:
score, notes = 0.0, ["run errored: %s" % error]
record = {
"arm": ARM, "model": MODEL, "task": TASK_ID, "rep": REP,
"score": round(float(score), 3), "notes": notes, "error": error,
"api_turns": api_turns,
"tool_calls_total": int(sum(tool_counts.values())) + bridge_calls,
"bridge_calls": bridge_calls,
"tool_counts": tool_counts,
"prompt_tokens": getattr(agent, "session_prompt_tokens", None),
"completion_tokens": getattr(agent, "session_completion_tokens", None),
"total_tokens": getattr(agent, "session_total_tokens", None),
"wall_s": round(wall, 1),
"raw_xml_noise": raw_xml_noise,
"user_roundtrips": user_roundtrips,
"clarify_invocations": len([c for c in CALLBACK_LOG if c["name"] == "clarify"]),
"final_answer": (final_answer or "")[:2000],
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT + ".transcript.json", "w", encoding="utf-8") as f:
json.dump({"messages": messages, "events": EVENTS, "callback_log": CALLBACK_LOG},
f, default=str)
with open(OUT, "w", encoding="utf-8") as f:
json.dump(record, f, indent=1, default=str)
print(json.dumps({k: record[k] for k in ("arm", "model", "task", "rep", "score",
"api_turns", "total_tokens", "wall_s",
"bridge_calls", "error")}))
try:
agent.close()
except Exception:
pass
shutil.rmtree(tmp_root, ignore_errors=True)