Files
TekniumandUtku Bakir 1780f3fadc fix(acp): editor users can answer the agent's clarification questions
Gateway turns install a blocking clarify callback, and a session's frozen
tools can include clarify (any CLI/TUI-created session the editor
attaches to). The ACP projection dropped every `clarify.request` and the
snapshot-restore helper ignored pending clarify prompts, so with the
editor as the only viewer the turn waited on a question nobody could see
until the clarify timeout.

GatewayACPAgent now projects canonical clarify prompts, live and restored,
through the same generation-keyed task map as approvals, and answers via
`clarify.respond` with the exact prompt_id/execution_generation:
- clients that advertise `elicitation.form` in initialize get an
  `elicitation/create` form (free text, or the offered choices);
- every other client gets a permission card with one option per choice
  plus Skip, so the question is visible and the wait can always be ended.
Transport loss or an editor-side cancel is not an answer: the canonical
waiter stays answerable by other viewers, as for approvals.
`clarify.settled` cancels the editor request. Execution stays
gateway-owned; tool policy is unchanged.

Form schema and capability detection adapted from #126313.

Review: https://github.com/NousResearch/hermes-agent/pull/106742#discussion_r4209145050

Co-authored-by: Utku Bakir <74243531+utkubakir@users.noreply.github.com>
2026-10-07 20:47:55 -07:00

72 lines
3.4 KiB
Python

"""A canonical ``clarify`` prompt in an ACP editor, answered through ``clarify.respond``.
A client that advertises ``elicitation.form`` in ``initialize`` gets the question as an
``elicitation/create`` form (free text or the offered choices); any other client gets a permission
card whose options are the choices plus Skip, the one interaction every ACP client renders.
Form schema and capability detection adapted from #126313 (Utku Bakir).
"""
from __future__ import annotations
import json
from typing import Any
ELICITATION_METHOD = "elicitation/create"
SKIP_OPTION = "skip"
def client_supports_form_elicitation(initialize_params: Any) -> bool:
"""ACP names an elicitation mode as supported only by its presence (``"form": {}``), under
v1 ``clientCapabilities`` or v2 ``capabilities``. Read from the raw initialize params:
the pinned SDK's ``ClientCapabilities`` model drops the field."""
if not isinstance(initialize_params, dict):
return False
for key in ("clientCapabilities", "capabilities"):
capabilities = initialize_params.get(key)
elicitation = capabilities.get("elicitation") if isinstance(capabilities, dict) else None
if isinstance(elicitation, dict) and elicitation.get("form") is not None:
return True
return False
def build_clarify_elicitation(session_id: str, prompt: dict) -> dict[str, Any]:
choices = list(prompt.get("choices") or [])
schema: dict[str, Any] = {"type": "string"}
if choices:
schema = {"type": "string", "enum": choices}
if prompt.get("multi_select"):
schema = {"type": "array", "items": schema}
return {"sessionId": session_id, "mode": "form", "message": prompt["question"],
"requestedSchema": {"type": "object", "properties": {"answer": schema}, "required": ["answer"]}}
def elicited_answer(response: Any) -> str | None:
"""Accept -> the answer (a multi-select list as the JSON array clarify decodes); decline or
cancel -> ``""``, the skip every surface sends; anything else -> ``None`` (not answered)."""
if not isinstance(response, dict) or response.get("action") not in {"accept", "decline", "cancel"}:
return None
if response["action"] != "accept":
return ""
value = (response.get("content") or {}).get("answer")
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return json.dumps(value, ensure_ascii=False)
return value if isinstance(value, str) else None
def build_clarify_permission(prompt: dict):
"""``(tool_call, options, answers)``: one option per offered choice plus Skip; ``answers``
maps each option id to the exact ``clarify.respond`` answer."""
import acp
from acp.schema import PermissionOption
choices = list(prompt.get("choices") or [])
options = [PermissionOption(option_id=f"choice-{index}", kind="allow_once", name=choice)
for index, choice in enumerate(choices)]
options.append(PermissionOption(option_id=SKIP_OPTION, kind="reject_once", name="Skip"))
answers = {f"choice-{index}": choice for index, choice in enumerate(choices)}
answers[SKIP_OPTION] = ""
tool_call = acp.update_tool_call(
f"clarify-{prompt['prompt_id']}", title=prompt["question"], kind="other", status="pending",
content=[acp.tool_content(acp.text_block(prompt["question"]))],
raw_input={"question": prompt["question"], "choices": choices})
return tool_call, options, answers