Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions api/core/agent/output_parser/cot_output_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,69 @@ class CotAgentOutputParser:
def handle_react_stream_output(
cls, llm_response: Generator[LLMResultChunk], usage_dict: dict[str, Any]
) -> Generator[str | AgentScratchpadUnit.Action]:
def parse_action(action) -> str | AgentScratchpadUnit.Action:
def _is_callable_action(action_name: Any, action_input: Any) -> bool:
# AgentScratchpadUnit.Action requires a str name and a dict/str
# input; anything else cannot be dispatched as a tool call.
return (
action_name is not None
and isinstance(action_name, str)
and action_input is not None
and isinstance(action_input, dict | str)
)

def _split_action_fields(action: dict) -> tuple[Any, Any]:
action_name = None
action_input = None
for key, value in action.items():
if "input" in key.lower():
action_input = value
elif action_name is None:
# The first non-input key names the action. Extra keys
# added after it (e.g. a trailing "reason") must not
# override the tool the agent is about to call.
action_name = value
return action_name, action_input

def parse_action(action) -> str | AgentScratchpadUnit.Action:
if isinstance(action, str):
try:
action = json.loads(action, strict=False)
except json.JSONDecodeError:
return action or ""

# cohere always returns a list
if isinstance(action, list) and len(action) == 1:
action = action[0]
if isinstance(action, list):
# The prompt allows exactly one action per turn, so a list of
# actions runs its first action-shaped item. A list without
# one (a direct answer that happens to be JSON data, or
# non-object items like [1]) is not an action and is kept as
# text - iterating it raised AttributeError on .items().
for item in action:
if not isinstance(item, dict):
continue
action_name, action_input = _split_action_fields(item)
if _is_callable_action(action_name, action_input):
return AgentScratchpadUnit.Action(
action_name=action_name,
action_input=action_input,
)
return json.dumps(action, ensure_ascii=False)

for key, value in action.items():
if "input" in key.lower():
action_input = value
else:
action_name = value
if not isinstance(action, dict):
return json.dumps(action, ensure_ascii=False)

if action_name is not None and action_input is not None:
action_name, action_input = _split_action_fields(action)
if _is_callable_action(action_name, action_input):
return AgentScratchpadUnit.Action(
action_name=action_name,
action_input=action_input,
)
else:
return json.dumps(action)
# A non-string action name (e.g. {"action": ["search"]}) or a
# non-dict/str action input is not callable; keep the payload
# as text instead of raising a validation error from
# AgentScratchpadUnit.Action.
return json.dumps(action, ensure_ascii=False)

def extra_json_from_code_block(code_block) -> list[list | dict]:
blocks = re.findall(r"```[json]*\s*([\[{].*[]}])\s*```", code_block, re.DOTALL | re.IGNORECASE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,76 @@ def test_edge_inputs(self, make_chunk, usage_dict, content) -> None:
if content.lower() in {"action:", "thought:"}:
assert "action:" not in joined.lower()
assert "thought:" not in joined.lower()

# --------------------------------------------------------
# Malformed action payloads (issue #45200)
# --------------------------------------------------------

def test_multi_item_json_list_is_not_an_action(self, make_chunk, usage_dict) -> None:
# A JSON list with more than one item is not a single action. The
# direct answer it carries must reach the user as text instead of
# raising AttributeError ('list' object has no attribute 'items').
content = '```json\n[{"name": "a"}, {"name": "b"}]\n```'
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert all(isinstance(item, str) for item in result)
assert json.loads("".join(result)) == [{"name": "a"}, {"name": "b"}]

@pytest.mark.parametrize("payload", ["[]", "[1]"])
def test_non_object_json_list_is_not_an_action(self, make_chunk, usage_dict, payload) -> None:
# An empty or non-object list used to raise AttributeError
# ('int' object has no attribute 'items'); it is plain text now.
content = f"```json\n{payload}\n```"
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert all(isinstance(item, str) for item in result)
assert json.loads("".join(result)) == json.loads(payload)

def test_multi_action_list_runs_first_action(self, make_chunk, usage_dict) -> None:
# The prompt allows exactly one action per turn: a list of actions
# runs the first one instead of crashing or running all of them.
content = '```json\n[{"action": "search", "input": "q1"}, {"action": "lookup", "input": "q2"}]\n```'
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert [item for item in result if isinstance(item, AgentScratchpadUnit.Action)] == [
AgentScratchpadUnit.Action(action_name="search", action_input="q1")
]

def test_non_string_action_name_is_text(self, make_chunk, usage_dict) -> None:
# A non-string action name used to raise a pydantic ValidationError
# from AgentScratchpadUnit.Action; it is treated as text now.
content = '{"action": ["search"], "action_input": {"q": "x"}}'
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert all(isinstance(item, str) for item in result)
assert json.loads("".join(result)) == {"action": ["search"], "action_input": {"q": "x"}}

def test_extra_field_after_action_does_not_override_tool(self, make_chunk, usage_dict) -> None:
# Extra fields after the action used to override the tool name (the
# last non-input key won, so a trailing "reason" became the action).
content = '{"action": "search", "action_input": {"q": "x"}, "reason": "need data"}'
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert result == [AgentScratchpadUnit.Action(action_name="search", action_input={"q": "x"})]

def test_non_action_json_preserves_non_ascii(self, make_chunk, usage_dict) -> None:
# json.dumps defaults escaped non-ASCII to \uXXXX sequences; keep the
# model's text as-is when a payload is not an action.
content = '{"foo": "中文"}'
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert result == ['{"foo": "中文"}']

@pytest.mark.parametrize(
"action_input",
[[1, 2], 5],
)
def test_wrong_typed_action_input_is_text(self, make_chunk, usage_dict, action_input) -> None:
# A non-dict/str action input used to raise a pydantic ValidationError
# from AgentScratchpadUnit.Action; it is treated as text now.
payload = {"action": "search", "action_input": action_input}
content = json.dumps(payload)
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert all(isinstance(item, str) for item in result)
assert json.loads("".join(result)) == payload