Skip to content

Commit 66d5b24

Browse files
enhancement: improve mcp agent parse json (#3207)
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
1 parent de59078 commit 66d5b24

File tree

5 files changed

+343
-28
lines changed

5 files changed

+343
-28
lines changed

camel/agents/mcp_agent.py

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,25 @@
1515
import asyncio
1616
import json
1717
import platform
18-
import re
19-
from typing import Any, Callable, Dict, List, Optional, Union, cast
18+
from typing import (
19+
TYPE_CHECKING,
20+
Any,
21+
Callable,
22+
Dict,
23+
List,
24+
Optional,
25+
Union,
26+
cast,
27+
)
2028

21-
from camel.agents import ChatAgent
29+
from camel.agents.chat_agent import ChatAgent
2230
from camel.logger import get_logger
2331
from camel.messages import BaseMessage
24-
from camel.models import BaseModelBackend, ModelFactory
32+
from camel.models.base_model import BaseModelBackend
33+
from camel.models.model_factory import ModelFactory
2534
from camel.prompts import TextPrompt
2635
from camel.responses import ChatAgentResponse
27-
from camel.toolkits import FunctionTool, MCPToolkit
36+
from camel.toolkits.function_tool import FunctionTool
2837
from camel.types import (
2938
BaseMCPRegistryConfig,
3039
MCPRegistryType,
@@ -33,6 +42,9 @@
3342
RoleType,
3443
)
3544

45+
if TYPE_CHECKING:
46+
from camel.toolkits.mcp_toolkit import MCPToolkit
47+
3648
# AgentOps decorator setting
3749
try:
3850
import os
@@ -44,6 +56,8 @@
4456
except (ImportError, AttributeError):
4557
from camel.utils import track_agent
4658

59+
from camel.parsers.mcp_tool_call_parser import extract_tool_calls_from_text
60+
4761
logger = get_logger(__name__)
4862

4963

@@ -168,8 +182,10 @@ def __init__(
168182
**kwargs,
169183
)
170184

171-
def _initialize_mcp_toolkit(self) -> MCPToolkit:
185+
def _initialize_mcp_toolkit(self) -> "MCPToolkit":
172186
r"""Initialize the MCP toolkit from the provided configuration."""
187+
from camel.toolkits.mcp_toolkit import MCPToolkit
188+
173189
config_dict = {}
174190
for registry_config in self.registry_configs:
175191
config_dict.update(registry_config.get_config())
@@ -334,27 +350,14 @@ async def astep(
334350
task = f"## Task:\n {input_message}"
335351
input_message = str(self._text_tools) + task
336352
response = await super().astep(input_message, *args, **kwargs)
337-
content = response.msgs[0].content.lower()
338-
339-
tool_calls = []
340-
while "```json" in content:
341-
json_match = re.search(r'```json', content)
342-
if not json_match:
343-
break
344-
json_start = json_match.span()[1]
345-
346-
end_match = re.search(r'```', content[json_start:])
347-
if not end_match:
348-
break
349-
json_end = end_match.span()[0] + json_start
350-
351-
tool_json = content[json_start:json_end].strip('\n')
352-
try:
353-
tool_calls.append(json.loads(tool_json))
354-
except json.JSONDecodeError:
355-
logger.warning(f"Failed to parse JSON: {tool_json}")
356-
continue
357-
content = content[json_end:]
353+
raw_content = response.msgs[0].content if response.msgs else ""
354+
content = (
355+
raw_content
356+
if isinstance(raw_content, str)
357+
else str(raw_content)
358+
)
359+
360+
tool_calls = extract_tool_calls_from_text(content)
358361

359362
if not tool_calls:
360363
return response

camel/parsers/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14+
"""Helper parsers used across the CAMEL project."""
15+
16+
from .mcp_tool_call_parser import extract_tool_calls_from_text
17+
18+
__all__ = ["extract_tool_calls_from_text"]
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14+
"""Utility functions for parsing MCP tool calls from model output."""
15+
16+
import ast
17+
import json
18+
import logging
19+
import re
20+
from typing import Any, Dict, List, Optional
21+
22+
try: # pragma: no cover - optional dependency
23+
import yaml
24+
except ImportError: # pragma: no cover
25+
yaml = None # type: ignore[assignment]
26+
27+
28+
CODE_BLOCK_PATTERN = re.compile(
29+
r"```(?:[a-z0-9_-]+)?\s*([\s\S]+?)\s*```",
30+
re.IGNORECASE,
31+
)
32+
33+
JSON_START_PATTERN = re.compile(r"[{\[]")
34+
JSON_TOKEN_PATTERN = re.compile(
35+
r"""
36+
(?P<double>"(?:\\.|[^"\\])*")
37+
|
38+
(?P<single>'(?:\\.|[^'\\])*')
39+
|
40+
(?P<brace>[{}\[\]])
41+
""",
42+
re.VERBOSE,
43+
)
44+
45+
logger = logging.getLogger(__name__)
46+
47+
48+
def extract_tool_calls_from_text(content: str) -> List[Dict[str, Any]]:
49+
"""Extract tool call dictionaries from raw text output."""
50+
51+
if not content:
52+
return []
53+
54+
tool_calls: List[Dict[str, Any]] = []
55+
seen_ranges: List[tuple[int, int]] = []
56+
57+
for match in CODE_BLOCK_PATTERN.finditer(content):
58+
snippet = match.group(1).strip()
59+
if not snippet:
60+
continue
61+
62+
parsed = _try_parse_json_like(snippet)
63+
if parsed is None:
64+
logger.warning(
65+
"Failed to parse JSON payload from fenced block: %s",
66+
snippet,
67+
)
68+
continue
69+
70+
_collect_tool_calls(parsed, tool_calls)
71+
seen_ranges.append((match.start(1), match.end(1)))
72+
73+
for start_match in JSON_START_PATTERN.finditer(content):
74+
start_idx = start_match.start()
75+
76+
if any(start <= start_idx < stop for start, stop in seen_ranges):
77+
continue
78+
79+
segment = _find_json_candidate(content, start_idx)
80+
if segment is None:
81+
continue
82+
83+
end_idx = start_idx + len(segment)
84+
if any(start <= start_idx < stop for start, stop in seen_ranges):
85+
continue
86+
87+
parsed = _try_parse_json_like(segment)
88+
if parsed is None:
89+
logger.debug(
90+
"Unable to parse JSON-like candidate: %s",
91+
_truncate_snippet(segment),
92+
)
93+
continue
94+
95+
_collect_tool_calls(parsed, tool_calls)
96+
seen_ranges.append((start_idx, end_idx))
97+
98+
return tool_calls
99+
100+
101+
def _collect_tool_calls(
102+
payload: Any, accumulator: List[Dict[str, Any]]
103+
) -> None:
104+
"""Collect valid tool call dictionaries from parsed payloads."""
105+
106+
if isinstance(payload, dict):
107+
if payload.get("tool_name") is None:
108+
return
109+
accumulator.append(payload)
110+
elif isinstance(payload, list):
111+
for item in payload:
112+
_collect_tool_calls(item, accumulator)
113+
114+
115+
def _try_parse_json_like(snippet: str) -> Optional[Any]:
116+
"""Parse a JSON or JSON-like snippet into Python data."""
117+
118+
try:
119+
return json.loads(snippet)
120+
except json.JSONDecodeError as exc:
121+
logger.debug(
122+
"json.loads failed: %s | snippet=%s",
123+
exc,
124+
_truncate_snippet(snippet),
125+
)
126+
127+
if yaml is not None:
128+
try:
129+
return yaml.safe_load(snippet)
130+
except yaml.YAMLError:
131+
pass
132+
133+
try:
134+
return ast.literal_eval(snippet)
135+
except (ValueError, SyntaxError):
136+
return None
137+
138+
139+
def _find_json_candidate(content: str, start_idx: int) -> Optional[str]:
140+
"""Locate a balanced JSON-like segment starting at ``start_idx``."""
141+
142+
opening = content[start_idx]
143+
if opening not in "{[":
144+
return None
145+
146+
stack = ["}" if opening == "{" else "]"]
147+
148+
for token in JSON_TOKEN_PATTERN.finditer(content, start_idx + 1):
149+
if token.lastgroup in {"double", "single"}:
150+
continue
151+
152+
brace = token.group("brace")
153+
if brace in "{[":
154+
stack.append("}" if brace == "{" else "]")
155+
continue
156+
157+
if not stack:
158+
return None
159+
160+
expected = stack.pop()
161+
if brace != expected:
162+
return None
163+
164+
if not stack:
165+
return content[start_idx : token.end()]
166+
167+
return None
168+
169+
170+
def _truncate_snippet(snippet: str, limit: int = 120) -> str:
171+
"""Return a truncated representation suitable for logging."""
172+
173+
compact = " ".join(snippet.strip().split())
174+
if len(compact) <= limit:
175+
return compact
176+
return f"{compact[: limit - 3]}..."

camel/toolkits/mcp_toolkit.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
from typing_extensions import TypeGuard
2222

2323
from camel.logger import get_logger
24-
from camel.toolkits import BaseToolkit, FunctionTool
24+
from camel.toolkits.base import BaseToolkit
25+
from camel.toolkits.function_tool import FunctionTool
2526
from camel.utils.commons import run_async
2627
from camel.utils.mcp_client import MCPClient, create_mcp_client
2728

0 commit comments

Comments
 (0)