fix(hitl): compose the same pause text in every runtime and always declare the extension - #2472
Conversation
There was a problem hiding this comment.
Skimmed over the changes here and they make sense to me. When this is ready for review, I'll take another look. I think that Go ADK wasn't originally intended to be used as BYO, but it's good to fix the sharp edges
EDIT: It would be helpful if you could rewrite the PR description a bit. I read it at first as “Go agents fail to declare the HITL extension,” which was confusing. What you actually mean is that Go only declares it when cfg.Agent is set for skill derivation, and you want that declaration to happen unconditionally, the same way Python does.
This isn't true, we DEFINITELY want to support go for BYO |
7eadeca to
4983c25
Compare
There was a problem hiding this comment.
Pull request overview
This PR standardizes Human-in-the-Loop (HITL) pause/status text composition across the Go ADK runtime and Python runtimes (kagent-adk + kagent-langgraph), and makes Go always declare the optional HITL extension on the served AgentCard so clients can reliably negotiate it.
Changes:
- Add a shared “pause text” composition rule (
hitl_status_text/hitlStatusText) that preserves tool hints and always names tools (and rendersask_useras its question text). - Update runtimes to emit identical human-readable text whether or not the HITL extension is activated, and ensure activated payloads carry the same string in
hint. - Ensure the Go ADK app always declares the HITL extension on the AgentCard and avoid clobbering existing capabilities when enabling streaming.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| python/packages/kagent-langgraph/tests/test_langgraph_executor.py | Adds coverage for interrupt handling (activated vs non-activated, ask_user vs approvals). |
| python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py | Uses shared HITL text composition; emits AskUserRequest for ask_user interrupts when activated. |
| python/packages/kagent-core/tests/test_hitl_utils.py | Adds unit tests for the new HITL status text composition behavior. |
| python/packages/kagent-core/src/kagent/core/a2a/_hitl.py | Introduces GENERIC_HITL_TEXT and hitl_status_text() for consistent pause text rendering. |
| python/packages/kagent-core/src/kagent/core/a2a/init.py | Re-exports hitl_status_text. |
| python/packages/kagent-adk/tests/unittests/test_hitl.py | Expands tests to validate consistent status text and activation behavior. |
| python/packages/kagent-adk/src/kagent/adk/_hitl.py | Uses hitl_status_text, preserves all hints, and skips malformed confirmation parts. |
| go/adk/pkg/app/app.go | Ensures HITL extension is declared on the served AgentCard regardless of whether an ADK agent is provided. |
| go/adk/pkg/app/app_test.go | Adds tests validating HITL extension declaration and non-mutation of caller-provided cards. |
| go/adk/pkg/a2a/hitl.go | Implements Go-side pause text composition and ensures payload hint matches the rendered text. |
| go/adk/pkg/a2a/hitl_test.go | Extends tests for consistent text composition and activation behavior. |
| go/adk/pkg/a2a/agentcard.go | Refactors HITL declaration into EnsureHITLExtension. |
| go/adk/pkg/a2a/agentcard_test.go | Adds unit tests for EnsureHITLExtension behavior. |
| go/adk/cmd/main.go | Stops overwriting Capabilities wholesale when setting Streaming. |
| docs/architecture/human-in-the-loop.md | Documents the unified status text composition rules and updates examples. |
Suppressed comments (1)
python/packages/kagent-adk/src/kagent/adk/_hitl.py:131
_tool_from_confirmation_datacurrently defaults a missingoriginalFunctionCall.nameto the literal string"tool", which means malformed confirmation parts may no longer be skipped and can surface as an approval for a fake tool name. If the tool name isn't a non-empty string, it should be treated as unusable so the caller can skip the part via the existingValidationErrorhandling.
return HitlTool(
id=str(data.get("id") or ""),
call_id=str(original.get("id") or data.get("id") or ""),
name=str(original.get("name") or "tool"),
args=original.get("args") if isinstance(original.get("args"), dict) else {},
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A client that does not activate the HITL extension received a single fixed sentence on the input-required status message, so the tool name, the tool's hint and the ask_user question were all dropped. The extension is optional and the architecture doc promises such a client "an ordinary input-required task with human-readable text", which one constant sentence is not. Parse the confirmation parts before deciding on activation, and build the text part from the tool's hint, falling back to the tool names. The text is the same whether or not the extension is activated; activation adds the typed payload on top. The ask_user tools already derive their hint from the questions, so the question survives too. In Go the tool approval payload's hint no longer defaults to that sentence when no tool supplied one; it is omitted, as it already was in Python. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
The Go card declaration lived in EnrichAgentCard, which app.New only calls when an ADK agent is supplied for skill derivation. An agent built around its own executor therefore served a card without the extension, leaving a client no way to discover it and negotiate. Move the declaration into EnsureHITLExtension and call it unconditionally, before the card is copied into the server. Skill derivation, the description backfill and the interface default stay behind the agent check. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
The non-activated text is built by one function per language, from the tools and the hints they supplied: an ask_user pause renders its questions, any other pause renders the hints followed by the tool names, and a pause with no tool call keeps the generic sentence. An activated payload carries that same string as its hint, so a client can render either field. Go kept the last hint and Python the first when several tools supplied one; both now keep all of them, so a batch pause no longer hides the tools it did not quote. LangGraph question interrupts become ask_user requests instead of rendering as an approval for a tool named ask_user. A confirmation part that carries no usable tool call is skipped rather than raising, matching what the Go parser already tolerated. The LangGraph executor test is renamed: sharing a basename with the CrewAI one made pytest abort collection for the whole suite. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
EnrichAgentCard declares the HITL extension again, so a caller using it directly instead of app.New keeps the behaviour it had. app.New assembles the served card in buildAgentCard, which declares the extension whether or not an ADK agent was supplied and leaves the caller's card untouched. The Go entrypoint replaced Capabilities wholesale to set Streaming, dropping the extensions the controller renders into agent-card.json. It now sets the one field. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
An action_request missing a name or an id is skipped, its args are normalized to an object, and a pause left with no usable request stays text-only rather than carrying an empty tools list that fails validation. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
92bc033 to
55feba5
Compare
The HITL extension is optional, and the architecture doc states what a client that does
not opt in should still get:
Today that is one constant sentence in every runtime, so the tool name, the tool's hint
and the
ask_userquestion are all lost. On the Go path an agent could also serve a cardthat never declares the extension, leaving a client no way to discover it.
The pause text
One function per runtime builds the text from the pending tools and the hints they
supplied:
ask_userasking "Which namespace?"Which namespace?delete_filewith a tool hintDeleting this file requires approval (delete_file)delete_filewith no hintApproval is required for tool(s): delete_filedelete_fileandrestart_pod, one hintDeleting this file requires approval (delete_file, restart_pod)Human input is required before the agent can continue.Every hint is kept rather than one of them winning: Go used to keep the last, Python the
first, so the same pause read differently per runtime. Tool names always accompany a
hint, so a pause on several tools no longer hides the ones the hint does not mention. The
text is identical whether or not the extension is activated, and an activated payload
repeats it in
hint.Two payload-visible changes:
tool_approval_request.hintis now always populated, and aLangGraph question interrupt emits an
ask_user_requestinstead of an approval for a toolnamed
ask_user, which the resume side already handled.Malformed input degrades instead of failing the task. A confirmation part with no usable
tool call, or a LangGraph
action_requestwithout a name or an id, is skipped, and apause left with no usable request stays text-only.
The card declaration
app.Newdeclares the extension whether or not an ADK agent was supplied for skillderivation, matching what both Python builders already do in
build().EnrichAgentCardkeeps declaring it, so a caller using that exported function directly is unaffected.
The Go entrypoint replaced
Capabilitieswholesale to setStreaming, discarding theextensions the control plane renders into
agent-card.json. It now sets the one field.Not included
kagent-crewaiandkagent-openaido not declare the extension. Neither has a pausepath: their executors only emit
submitted,working,completedandfailed, soCrewAI's
human_inputand the Agents SDK's tool interruptions never reach A2A. Wiringthose runtimes up is worth doing separately.