From 98f6995b8be45a6e1d83feffb78d90d8a2649ac6 Mon Sep 17 00:00:00 2001 From: abdelmoumen_mezhoud Date: Thu, 13 Aug 2026 14:37:57 +0400 Subject: [PATCH] fix(langgraph): an enum output is not free text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single string output is treated as the agent's own answer: no structured generation is requested, and the output is filled from the final message so a text agent still works on models without structured-output support. An enum was caught by that rule, because an enum *is* a string. The consequences were invisible: no `AgentOutputModel` tool was ever bound, the model replied in prose, and the free-text fallback stored that whole sentence as the value — so the declared options were never enforced. An agent with one enum output returned sentences instead of one of its own values, and a `BranchingNode` keyed on it matched none of its paths (raising `KeyError: 'default'` when no otherwise edge exists, or silently taking the fallback when one does). An enum's schema names the only values the output may take, so free text can never satisfy it: it now takes the structured path, where the tool call is forced and the value is validated against the allowed set. A plain string output keeps the free-text shortcut unchanged. Verified: the new test fails without the change, and a live flow whose classifier declares one enum output now returns `{"sentiment": "positive"}` where it previously returned prose. Co-Authored-By: Claude Opus 5 --- .../adapters/langgraph/_node_execution.py | 10 +++++++++- .../langgraph/flows/test_agentnode.py | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 4bcb1260..b2aa13b8 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -1067,9 +1067,17 @@ def is_single_string_output(expected_outputs: List[AgentSpecProperty]) -> bool: adapter takes it directly from the agent's final message rather than forcing structured generation. Mirrors ``LlmNodeExecutor``'s single-string handling and lets a string output work on models without structured-output support. + + An enum is excluded: its schema names the only values the output may take, so + free text cannot satisfy it. Treating it as free text left the allowed set + unenforced — the agent replied in prose and a branch keyed on the value + matched nothing. """ outputs = expected_outputs or [] - return len(outputs) == 1 and outputs[0].type == "string" + if len(outputs) != 1 or outputs[0].type != "string": + return False + schema = outputs[0].json_schema or {} + return not schema.get("enum") def extract_outputs_from_invoke_result( diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py index 8930aede..afd53dce 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py @@ -107,6 +107,26 @@ def test_is_single_string_output() -> None: assert is_single_string_output([StringProperty(title="a"), StringProperty(title="b")]) is False +def test_a_single_enum_output_is_not_free_text() -> None: + """An enum names the only values its output may take, so it must go through + structured generation rather than being filled from the final message. + + Treated as free text, the allowed set was never enforced: the agent replied in + prose and a BranchingNode keyed on the value matched none of its paths. + """ + from pyagentspec.adapters.langgraph._node_execution import is_single_string_output + from pyagentspec.property import Property, StringProperty + + enum_output = Property( + title="sentiment", + json_schema={"type": "string", "enum": ["positive", "negative", "neutral"]}, + ) + + assert is_single_string_output([enum_output]) is False + # A plain string output keeps the free-text shortcut. + assert is_single_string_output([StringProperty(title="answer")]) is True + + def test_single_string_output_taken_from_final_message_without_structured_generation() -> None: """An AgentNode whose agent declares a single string output should resolve that output from the agent's final message — no structured generation, so