Stop reporting a silent workflow as a completed task
n8n answers 200 with an empty body when a node throws before the Respond node runs — a refused token exchange is the common way in. The adapter read that as a completed task carrying "(the workflow returned nothing)", which tells the caller the task succeeded: the one thing certainly false. Off-contract replies get the same treatment. They are still returned raw so drift is visible, but a body that is not the contract cannot be read as agreement. Also removes a backlog section that described the events as unbuilt and said an event should carry sub and azp from the caller's token — the design the user overruled. Anyone reading it would have built the wrong consumer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -27,6 +27,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from enum import Enum, auto
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
@@ -152,12 +153,14 @@ class WorkflowExecutor(AgentExecutor):
|
||||
)
|
||||
return
|
||||
|
||||
answer, escalate = _read(response)
|
||||
if escalate:
|
||||
# An autonomous agent that meets a rule needing approval must fail
|
||||
# cleanly and escalate, never decide. The caller sees a task that
|
||||
# did not complete, which is the honest shape for "a human has to
|
||||
# look at this".
|
||||
answer, outcome = _read(response)
|
||||
if outcome is not Outcome.ANSWERED:
|
||||
# Three different failures, one honest shape. An agent that met a
|
||||
# rule needing approval must escalate rather than decide; a workflow
|
||||
# that answered nothing did not answer; a workflow that answered off
|
||||
# contract cannot be read as agreement. `completed` on any of them
|
||||
# tells the caller the task succeeded, which is the one thing that is
|
||||
# certainly false.
|
||||
await updater.failed(message=updater.new_agent_message(parts=[Part(text=answer)]))
|
||||
return
|
||||
|
||||
@@ -167,23 +170,46 @@ class WorkflowExecutor(AgentExecutor):
|
||||
await TaskUpdater(event_queue, context.task_id, context.context_id).cancel()
|
||||
|
||||
|
||||
def _read(response: httpx.Response) -> tuple[str, bool]:
|
||||
"""What the workflow said, and whether it is escalating.
|
||||
class Outcome(Enum):
|
||||
"""What came back, which is not the same question as whether HTTP worked."""
|
||||
|
||||
A workflow answers with `{"answer": "...", "escalate": false}`. Anything else
|
||||
is returned as raw text rather than guessed at — a workflow that has drifted
|
||||
from the contract should be visible, not silently reinterpreted.
|
||||
#: The contract was honoured and the workflow is standing behind its answer.
|
||||
ANSWERED = auto()
|
||||
#: The workflow answered, and is handing the decision to a human.
|
||||
ESCALATED = auto()
|
||||
#: HTTP 200 with an empty body. n8n does that when a node throws before the
|
||||
#: Respond node runs — a failed token exchange is the common way in. It is a
|
||||
#: failure that looks exactly like a success from the status code alone.
|
||||
SILENT = auto()
|
||||
#: A body that is not the contract. Returned raw rather than guessed at: a
|
||||
#: workflow that has drifted should be visible, not reinterpreted.
|
||||
OFF_CONTRACT = auto()
|
||||
|
||||
|
||||
def _read(response: httpx.Response) -> tuple[str, Outcome]:
|
||||
"""What the workflow said, and what kind of thing it said.
|
||||
|
||||
A workflow answers with `{"answer": "...", "escalate": false}`.
|
||||
"""
|
||||
body = response.text.strip()
|
||||
if not body:
|
||||
return (
|
||||
"The workflow produced no answer. It most often means a node threw "
|
||||
"before the reply was built — a refused token exchange, usually.",
|
||||
Outcome.SILENT,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return response.text.strip() or "(the workflow returned nothing)", False
|
||||
return body, Outcome.OFF_CONTRACT
|
||||
if isinstance(payload, list) and payload:
|
||||
# n8n returns an array when the workflow's last node emits items.
|
||||
payload = payload[0]
|
||||
if isinstance(payload, dict) and "answer" in payload:
|
||||
return str(payload["answer"]), bool(payload.get("escalate", False))
|
||||
return response.text.strip() or "(the workflow returned nothing)", False
|
||||
answer = str(payload["answer"])
|
||||
return answer, Outcome.ESCALATED if payload.get("escalate", False) else Outcome.ANSWERED
|
||||
return body, Outcome.OFF_CONTRACT
|
||||
|
||||
|
||||
def _subject_token(context: RequestContext) -> str | None:
|
||||
|
||||
Binary file not shown.
@@ -11,7 +11,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from a2a_n8n_adapter.config import Settings
|
||||
from a2a_n8n_adapter.server import _read, build_agent_card
|
||||
from a2a_n8n_adapter.server import Outcome, _read, build_agent_card
|
||||
|
||||
|
||||
def settings(**over) -> Settings:
|
||||
@@ -35,27 +35,35 @@ def response(payload, status=200) -> httpx.Response:
|
||||
|
||||
class TestReadingTheWorkflow:
|
||||
def test_the_contracted_shape(self) -> None:
|
||||
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", False)
|
||||
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", Outcome.ANSWERED)
|
||||
|
||||
def test_n8n_wraps_its_last_node_in_an_array(self) -> None:
|
||||
assert _read(response([{"answer": "yes"}])) == ("yes", False)
|
||||
assert _read(response([{"answer": "yes"}])) == ("yes", Outcome.ANSWERED)
|
||||
|
||||
def test_escalation_is_carried_not_swallowed(self) -> None:
|
||||
# An autonomous agent meeting a rule that needs approval must escalate.
|
||||
# Losing this flag would turn "a human must look" into "done".
|
||||
assert _read(response({"answer": "needs approval", "escalate": True})) == (
|
||||
"needs approval",
|
||||
True,
|
||||
Outcome.ESCALATED,
|
||||
)
|
||||
|
||||
def test_an_off_contract_reply_is_returned_raw(self) -> None:
|
||||
# Visible rather than reinterpreted: a workflow that has drifted should
|
||||
# look wrong, not be quietly guessed at.
|
||||
text, escalate = _read(response("plain text"))
|
||||
assert text == "plain text" and escalate is False
|
||||
# Off contract is not agreement. Returned raw so the drift is visible,
|
||||
# but never as a completed answer.
|
||||
text, outcome = _read(response("plain text"))
|
||||
assert text == "plain text" and outcome is Outcome.OFF_CONTRACT
|
||||
|
||||
def test_an_empty_reply_says_so(self) -> None:
|
||||
assert _read(response("")) == ("(the workflow returned nothing)", False)
|
||||
# HTTP 200 with an empty body is what n8n returns when a node throws
|
||||
# before the reply is built — a refused token exchange, usually. Reading
|
||||
# it as a completed answer told the caller the task succeeded, which was
|
||||
# the one thing certainly false.
|
||||
text, outcome = _read(response(""))
|
||||
assert outcome is Outcome.SILENT
|
||||
assert "no answer" in text
|
||||
|
||||
|
||||
class TestAgentCard:
|
||||
|
||||
Reference in New Issue
Block a user